diff --git a/README.md b/README.md
index dd833af..9031cdd 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,110 @@
-# Custom Branding
+# CustomBranding
-Custom Branding ist ein SPFx-1.4.1-Application-Customizer fuer SharePoint Server Subscription Edition. Die Solution laedt zentral konfigurierte Stylesheets und kann strukturierte Inhalte in den oberen und unteren SharePoint-Placeholder rendern.
+CustomBranding 3.0 ist ein zentraler SPFx-1.4.1-Application-Customizer für SharePoint Server Subscription Edition. Die Solution lädt freigegebene Stylesheets und rendert eine kontrollierte Komponentenstruktur im oberen oder unteren SharePoint-Placeholder. Moderne und klassische Seiten verwenden dieselben `ClientSideComponentProperties`.
-## Version
+Die Konfiguration liegt in genau einer `SPSite.UserCustomAction` pro Site Collection. Es werden weder eine versteckte Liste noch ein Property Bag benötigt. Dadurch gilt das Branding automatisch für das Root Web, vorhandene Subwebs und später angelegte Subwebs.
-`1.0.4.0`
+## Architektur
+
+```text
+App Catalog
+└── zentral bereitgestelltes SPFx-Bundle
+
+Site Collection
+├── SPSite.UserCustomAction mit ClientSideComponentProperties
+├── moderne Seiten: SPFx Application Customizer
+└── klassische Seiten: optionaler SPSite ScriptLink
+
+CustomHeader
+├── CustomBrandingTopHost
+└── MegaMenuHost (wird nicht verändert)
+
+CustomFooter
+└── CustomBrandingBottomHost
+```
+
+CustomBranding verändert nur seine eigenen Host-Elemente. Das MegaMenu und andere Erweiterungen im selben Placeholder bleiben bei Navigation und erneutem Rendern erhalten.
+
+## Voraussetzungen und Build
+
+- SharePoint Server Subscription Edition
+- SPFx 1.4.1
+- Node.js 8.17.0
+- npm 6.13.4
+- lokale Gulp-Version 3.9.1
+
+```powershell
+npm install
+npm test
+npm run package
+```
+
+`npm run package` führt zuerst die Tests und den Classic-Build aus. Das SharePoint-Paket entsteht unter `sharepoint/solution/custom-branding.sppkg`. Die Classic-Dateien liegen anschließend unter `classic/dist`.
+
+Die alte SPFx-Toolchain lässt sich mit aktuellen Node-Versionen nicht zuverlässig paketieren. Der abschließende Ship-Build muss deshalb mit Node.js 8.17.0 erfolgen.
+
+## Installation und zentrale Registrierung
+
+1. `custom-branding.sppkg` im App Catalog hochladen oder ersetzen und zentral bereitstellen.
+2. Das Skript in der SharePoint Management Shell ausführen.
+3. Eine moderne Seite mit `Strg+F5` neu laden.
+
+```powershell
+.\deployment\add-custombranding.ps1 `
+ -SiteUrl 'http://clshp001/sites/portal' `
+ -CssPath '~sitecollection/SiteAssets/branding/custom-branding.css'
+```
+
+Das Skript arbeitet idempotent: Es erhält vorhandene Properties, entfernt doppelte oder alte web-scoped Registrierungen und erzeugt genau eine site-scoped Action. Ein bewusstes Zurücksetzen erfolgt nur mit `-ResetConfiguration`.
+
+Weitere Optionen:
+
+```powershell
+# Debug-Ausgaben einschalten und einen externen HTTPS-CSS-Host freigeben
+.\deployment\add-custombranding.ps1 `
+ -SiteUrl 'http://clshp001/sites/portal' `
+ -EnableDebug `
+ -AllowedCssHost 'cdn.example.org'
+
+# Extension deaktivieren, Konfiguration aber erhalten
+.\deployment\add-custombranding.ps1 `
+ -SiteUrl 'http://clshp001/sites/portal' `
+ -Disable
+```
## Konfiguration
-Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCustomAction gespeichert und zentral ueber PortalSettings gepflegt:
+Das aktuelle Konfigurationsschema hat die Version 2:
```json
{
- "cssfiles": [],
+ "schemaVersion": 2,
+ "enabled": true,
+ "debug": false,
+ "allowedCssHosts": [],
+ "cssfiles": [
+ {
+ "path": "~sitecollection/SiteAssets/branding/custom-branding.css",
+ "media": "all"
+ }
+ ],
"placeholdertop": {
- "elements": []
+ "elements": [
+ {
+ "type": "section",
+ "attributes": {
+ "class": "custom-branding-banner",
+ "role": "region",
+ "aria-label": "Portalhinweis"
+ },
+ "children": [
+ {
+ "type": "strong",
+ "content": "Willkommen im Portal"
+ }
+ ]
+ }
+ ]
},
"placeholderbottom": {
"elements": []
@@ -22,22 +112,92 @@ Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCu
}
```
-## Build
+Bestehende 1.x-Konfigurationen mit einem Root-Array `elements` werden weiterhin als Top-Inhalt gelesen. Unbekannte Properties ignoriert die Runtime. Das vollständige Beispiel liegt unter `examples/custom-branding.example.json`.
-Die Legacy-SPFx-Toolchain benoetigt Node.js 8.17.0:
+### Sicherheitsgrenzen
+
+Erlaubte Elemente:
+
+`div`, `span`, `p`, `a`, `button`, `img`, `h1`, `h2`, `h3`, `strong`, `em`, `nav`, `section`
+
+Attribute und Styles werden pro Element über feste Allowlisten geprüft. Insbesondere gelten folgende Regeln:
+
+- `script`, `iframe`, alle `on*`-Attribute, `javascript:`, `data:` und CSS mit `url()` oder `expression()` werden verworfen.
+- Bilder benötigen immer ein `alt`; dekorative Bilder verwenden `alt: ""`.
+- Leere Links und Buttons sind nicht erlaubt; Buttons erhalten immer `type="button"`.
+- Links mit `target="_blank"` erhalten automatisch `rel="noopener noreferrer"`.
+- Relative CSS-Pfade und CSS derselben Origin sind erlaubt. Fremde Quellen benötigen HTTPS und einen Eintrag in `allowedCssHosts`.
+- Maximal 20 Stylesheets, 200 Elemente, acht Ebenen und 100.000 Zeichen Konfiguration werden verarbeitet.
+
+Unsichere Teilwerte werden kontrolliert verworfen, ohne die SharePoint-Seite zu blockieren. Details erscheinen nur bei `debug: true` in der Browserkonsole mit dem Präfix `[CustomBranding]`.
+
+## Classic SharePoint
+
+Der SPFx Application Customizer selbst läuft nicht auf klassischen Seiten. Version 3.0 enthält deshalb eine separate ES5-Runtime mit demselben Sicherheits- und Konfigurationsmodell.
```powershell
-npm install
-npm run package
+npm run build:classic
```
-Das Paket wird unter `sharepoint/solution/custom-branding.sppkg` erzeugt.
+Danach `classic/dist/custom-branding-classic.js` nach beispielsweise `/SiteAssets/custom-branding/` hochladen und zentral registrieren:
-## Installation
+```powershell
+.\deployment\add-custombranding.ps1 `
+ -SiteUrl 'http://clshp001/sites/portal' `
+ -ClassicScriptUrl '~sitecollection/SiteAssets/custom-branding/custom-branding-classic.js'
+```
-1. `custom-branding.sppkg` in den App Catalog laden beziehungsweise ersetzen.
-2. Die App im Root Web der Site Collection installieren oder aktualisieren.
-3. Beim Aktivieren wird der Custom Branding Application Customizer automatisch web-scoped registriert.
-4. PortalSettings mit `Strg+F5` neu laden und den Tab **Custom Branding** konfigurieren.
+Der site-scoped ScriptLink gilt auch für später angelegte Subwebs. Entfernen lässt er sich mit `-RemoveClassicScriptLink`. Weitere Hinweise stehen in `classic/classic-deployment.md`.
-Das Skript `deployment/add-custombranding.ps1` bleibt fuer automatisierte Rollouts oder eine ausdruecklich site-scoped Registrierung verfuegbar.
+## PortalSettings v3
+
+CustomBranding funktioniert unabhängig von PortalSettings. Das geplante PortalSettings-v3-Webpart kann die Action anhand folgender Werte erkennen und die Properties schemaerhaltend bearbeiten:
+
+- Component ID: `035ba968-6488-4d42-86b3-0470ffcc95b9`
+- Location: `ClientSideExtension.ApplicationCustomizer`
+- Scope: `SPSite.UserCustomActions`
+- Schema: `schemaVersion: 2`
+
+Ein Editor muss unbekannte Properties erhalten und vor dem Speichern dieselben Element-, Attribut-, URL- und CSS-Grenzen beachten.
+
+## Diagnose
+
+Zentrale Registrierung prüfen:
+
+```powershell
+$site = Get-SPSite 'http://clshp001/sites/portal'
+$site.UserCustomActions | Where-Object {
+ $_.ClientSideComponentId -eq [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9'
+} | Select-Object Id, Title, Location, ClientSideComponentProperties
+$site.Dispose()
+```
+
+Browserkonsole auf einer modernen Seite:
+
+```javascript
+performance.getEntriesByType('resource')
+ .map(function (entry) { return entry.name; })
+ .filter(function (url) { return url.toLowerCase().indexOf('custom-branding') >= 0; });
+
+({
+ header: !!document.getElementById('CustomHeader'),
+ branding: !!document.getElementById('CustomBrandingTopHost'),
+ megaMenu: !!document.getElementById('MegaMenuHost')
+});
+```
+
+Classic-Diagnose:
+
+```javascript
+typeof window.CustomBrandingClassic
+window.CustomBrandingClassic.reload()
+```
+
+## Upgrade von 1.x
+
+1. Paket im App Catalog durch Version `3.0.0.0` ersetzen und bereitstellen.
+2. `add-custombranding.ps1` einmal pro Site Collection ausführen; bestehende Properties bleiben erhalten.
+3. Moderne und gegebenenfalls klassische Seiten testen.
+4. Erst nach erfolgreicher Abnahme alte web-scoped Aktionen als bereinigt bestätigen.
+
+Ein Downgrade sollte nur zusammen mit einer Sicherung der `ClientSideComponentProperties` erfolgen.
diff --git a/ToDo.md b/ToDo.md
new file mode 100644
index 0000000..bf44ce7
--- /dev/null
+++ b/ToDo.md
@@ -0,0 +1,120 @@
+# CustomBranding – Umsetzungsstand
+
+Analysestand: 20.07.2026
+Umgesetzt: 20.07.2026
+Ausgangsversion: 1.0.4
+Zielversion: 3.0.0
+Status: Implementierung und lokale Qualitätssicherung abgeschlossen
+
+## Zielbild
+
+CustomBranding wird pro Site Collection genau einmal über eine `SPSite.UserCustomAction` registriert und konfiguriert. Moderne und klassische Seiten lesen dieselben versionierten `ClientSideComponentProperties`. Es werden weder eine versteckte Liste noch ein Property Bag eingesetzt. PortalSettings bleibt in diesem Durchlauf unverändert; der Vertrag für das spätere PortalSettings-v3-Webpart ist in der README dokumentiert.
+
+## Sicherheit und Konfigurationsmodell
+
+- [x] Stringbasiertes HTML und `innerHTML` durch einen DOM-Renderer ersetzt.
+- [x] Laufzeit-Whitelist für Elementtypen umgesetzt.
+- [x] Globale und elementspezifische Attribut-Allowlisten umgesetzt.
+- [x] Alle Ereignisattribute `on*` gesperrt.
+- [x] Relative URLs, HTTP/HTTPS und `mailto` kontrolliert; `javascript:` und `data:` gesperrt.
+- [x] `target="_blank"` automatisch mit `noopener noreferrer` abgesichert.
+- [x] Style-Property-Allowlist und Prüfung gefährlicher CSS-Werte umgesetzt.
+- [x] Externe Stylesheets nur per HTTPS und expliziter Host-Allowlist ermöglicht.
+- [x] Limits für Konfigurationsgröße, Rekursion, Elemente und Stylesheets umgesetzt.
+- [x] Versioniertes Schema 2 eingeführt.
+- [x] Bestehende 1.x-Konfigurationen mit Root-Property `elements` weiter unterstützt.
+- [x] `enabled`, `debug`, `allowedCssHosts`, `cssfiles`, Top und Bottom konsistent typisiert.
+- [x] Unbekannte Properties werden ignoriert; Warnungen erscheinen nur im Debug-Modus.
+- [x] Keine feste Abhängigkeit oder Verlinkung zu PortalSettings mehr vorhanden.
+
+## Lifecycle und Stylesheet-Management
+
+- [x] Top- und Bottom-Placeholder werden unabhängig angefordert und gerendert.
+- [x] Nach dem Dispose eines einzelnen Placeholders kann dieser später erneut erworben werden.
+- [x] `changedEvent` wird beim Extension-Dispose deregistriert.
+- [x] Dispose ist idempotent und setzt alle eigenen Referenzen zurück.
+- [x] Eigene Hosts werden entfernt, fremde Knoten bleiben erhalten.
+- [x] CSS-URLs werden absolut normalisiert und instanzübergreifend dedupliziert.
+- [x] Reihenfolge und optionale `media`-Angabe bleiben erhalten.
+- [x] Ladefehler und Timeouts werden debug-gesteuert protokolliert.
+- [x] Fremde vorhandene Stylesheets werden wiederverwendet und nie entfernt.
+- [x] Nur Stylesheets der letzten eigenen Referenz werden beim Dispose entfernt.
+
+## Barrierefreiheit und Mehrsprachigkeit
+
+- [x] Bilder ohne `alt` werden verworfen; dekorative Bilder mit leerem `alt` sind möglich.
+- [x] Buttons erhalten immer `type="button"`; leere Links und Buttons werden verworfen.
+- [x] ARIA-Attribute sind auf eine feste Allowlist begrenzt.
+- [x] Fehlermeldungen verwenden `role="status"`.
+- [x] Deutsches und englisches Localized Resource Bundle ergänzt und verwendet.
+- [x] Debug-gesteuerter Logger mit einheitlichem Präfix umgesetzt.
+- [x] Beispiel-CSS mit sichtbaren Fokuszuständen, responsivem Layout und Forced-Colors-Regel ergänzt.
+- [ ] Tastaturbedienung, 200-Prozent-Zoom und Windows-Hochkontrast in der Zielumgebung manuell abnehmen.
+
+## Bereitstellung und Scope
+
+- [x] Paket auf `skipFeatureDeployment` und zentrale `SPSite.UserCustomAction` umgestellt.
+- [x] Alte web-scoped Feature-Registrierung und `sharepoint/assets/elements.xml` entfernt.
+- [x] Nicht verwendete Azure-Storage-Konfiguration entfernt.
+- [x] SharePoint-SE-Deploymentskript verwendet zuerst das PowerShell-Modul und nur als Fallback das Snap-in.
+- [x] Doppelte Site- und alte Web-Actions werden idempotent bereinigt.
+- [x] Vorhandene Properties bleiben standardmäßig erhalten.
+- [x] Bewusste Optionen für Reset, Debug, Disable und externe CSS-Hosts ergänzt.
+- [ ] Root Web, vorhandenes Subweb und anschließend neu angelegtes Subweb auf dem Zielserver abnehmen.
+
+## MegaMenu-Integration
+
+- [x] Stabile Hosts `CustomHeader`, `CustomBrandingTopHost`, `MegaMenuHost`, `CustomFooter` und `CustomBrandingBottomHost` definiert.
+- [x] Renderer verändert ausschließlich eigene Host-Inhalte.
+- [x] Wiederholtes Rendern löscht den `MegaMenuHost` oder fremde DOM-Knoten nicht.
+- [x] Gemeinsamer Betrieb und Ownership in der README dokumentiert.
+- [ ] Beide realen Lade-Reihenfolgen mit installiertem MegaMenu auf dem Zielserver abnehmen.
+
+## Classic SharePoint
+
+- [x] Classic-Unterstützung als fachliches Ziel bestätigt.
+- [x] Separate ES5-Runtime ohne SPFx-Abhängigkeit erstellt.
+- [x] Classic-Runtime verwendet dasselbe zentrale Schema und dieselben Sicherheitsgrenzen.
+- [x] Automatischer Classic-Build über `npm run build:classic` umgesetzt.
+- [x] Optionaler site-scoped ScriptLink im Deploymentskript ergänzt.
+- [x] Bereitstellung, Entfernung und Browserdiagnose dokumentiert.
+- [ ] Classic-Listen-, Bibliotheks- und Publishing-Seite auf dem Zielserver visuell abnehmen.
+
+## Projektbereinigung und Dokumentation
+
+- [x] Node-Engine auf `>=6.9.0 <9.0.0` gesetzt und Node.js 8.17.0 dokumentiert.
+- [x] Package und Solution konsistent auf Version 3.0.0 angehoben.
+- [x] Nicht verwendete Abhängigkeit `@microsoft/sp-dialog` entfernt.
+- [x] Localized Resources aktiviert und ergänzt.
+- [x] `serve.json` auf das echte, sichere Schema korrigiert.
+- [x] Nicht verwendete Dateien entfernt.
+- [x] README mit Architektur, Schema, Sicherheitsgrenzen, Deployment, Classic, Upgrade und Diagnose neu erstellt.
+- [x] Sichere JSON- und CSS-Beispiele ergänzt.
+
+## Automatisierte Qualitätssicherung
+
+- [x] Testbare Module für Normalisierung, URLs, Attribute, Styles, DOM und CSS-Loading ausgelagert.
+- [x] Positivtests für alle erlaubten Elementtypen ergänzt.
+- [x] Negativtests für Tags, Event-Attribute, URLs, Styles und fehlendes `alt` ergänzt.
+- [x] Tests für Legacy-Schema, Schema 2, Tiefen- und Größenlimit ergänzt.
+- [x] DOM-Test für sichere Elementerzeugung und Cleanup ergänzt.
+- [x] CSS-Tests für Deduplizierung, Reihenfolge, Referenzen und fremde Stylesheets ergänzt.
+- [x] Statische Prüfungen für JSON, Konfiguration, Lifecycle-Regeln, Classic-JavaScript und PowerShell ergänzt.
+- [x] `npm test` als zwingendes Gate vor `npm run package` eingebaut.
+- [x] Classic-Build als Teil der Paketierung automatisiert.
+- [x] Ship-Build mit Node.js 8.17.0 als abschließender Zielserver-Schritt dokumentiert.
+
+## Lokale Prüfergebnisse
+
+- [x] Hauptprojekt kompiliert mit TypeScript ohne Ausgabe.
+- [x] `npm test`: 32 Config-, 6 DOM-, 8 CSS- und 20 statische Prüfungen erfolgreich.
+- [x] PowerShell-Syntax für Deployment und Classic-Build erfolgreich geprüft.
+- [x] Classic-Build erzeugt beide Distributionsdateien erfolgreich.
+
+## Noch offene Abnahme auf SharePoint SE
+
+1. Ship-Paket mit Node.js 8.17.0 bauen und im App Catalog aktualisieren.
+2. Zentrale Registrierung mit `deployment/add-custombranding.ps1` herstellen.
+3. Moderne Seiten in Root Web, bestehendem und neuem Subweb prüfen.
+4. MegaMenu-Ladereihenfolgen sowie Tastatur, Zoom und Hochkontrast prüfen.
+5. Optional Classic-Runtime bereitstellen und klassische Seitentypen prüfen.
diff --git a/build-classic.ps1 b/build-classic.ps1
new file mode 100644
index 0000000..b537d2b
--- /dev/null
+++ b/build-classic.ps1
@@ -0,0 +1,25 @@
+param(
+ [string]$OutputPath = ''
+)
+
+$sourcePath = Join-Path $PSScriptRoot 'classic'
+$resolvedOutputPath = if ([string]::IsNullOrWhiteSpace($OutputPath)) {
+ Join-Path $sourcePath 'dist'
+}
+else {
+ $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
+}
+
+if (-not (Test-Path -LiteralPath $resolvedOutputPath)) {
+ New-Item -ItemType Directory -Path $resolvedOutputPath -Force | Out-Null
+}
+
+foreach ($file in @('custom-branding-classic.js', 'classic-deployment.md')) {
+ $source = Join-Path $sourcePath $file
+ if (-not (Test-Path -LiteralPath $source)) { throw ('Classic-Datei fehlt: ' + $source) }
+ Copy-Item -LiteralPath $source -Destination (Join-Path $resolvedOutputPath $file) -Force
+ Write-Host ('Kopiert: ' + $file) -ForegroundColor Green
+}
+
+Write-Host ('Classic-Paket erstellt: ' + $resolvedOutputPath) -ForegroundColor Green
+
diff --git a/classic/classic-deployment.md b/classic/classic-deployment.md
new file mode 100644
index 0000000..9617b76
--- /dev/null
+++ b/classic/classic-deployment.md
@@ -0,0 +1,30 @@
+# CustomBranding für klassische SharePoint-Seiten
+
+Die Classic-Runtime liest dieselbe zentrale `SPSite.UserCustomAction` wie der moderne Application Customizer. Sie rendert das abgesicherte Konfigurationsschema ohne SPFx-Abhängigkeit und lädt die freigegebenen Stylesheets.
+
+## Bereitstellung
+
+1. `npm run build:classic` ausführen.
+2. `classic/dist/custom-branding-classic.js` beispielsweise nach `/SiteAssets/custom-branding/` hochladen.
+3. Den site-scoped ScriptLink zusammen mit der zentralen Application-Customizer-Action registrieren:
+
+```powershell
+.\deployment\add-custombranding.ps1 `
+ -SiteUrl 'http://clshp001/sites/portal' `
+ -ClassicScriptUrl '~sitecollection/SiteAssets/custom-branding/custom-branding-classic.js'
+```
+
+Der ScriptLink gilt für Root Web, vorhandene Subwebs und später angelegte Subwebs der Site Collection. Zum Entfernen:
+
+```powershell
+.\deployment\add-custombranding.ps1 `
+ -SiteUrl 'http://clshp001/sites/portal' `
+ -RemoveClassicScriptLink
+```
+
+## Diagnose
+
+```javascript
+typeof window.CustomBrandingClassic
+window.CustomBrandingClassic.reload()
+```
diff --git a/classic/custom-branding-classic.js b/classic/custom-branding-classic.js
new file mode 100644
index 0000000..e44196e
--- /dev/null
+++ b/classic/custom-branding-classic.js
@@ -0,0 +1,263 @@
+/* CustomBranding 2.0.0 - safe Classic SharePoint runtime */
+(function (global) {
+ 'use strict';
+
+ var COMPONENT_ID = '035ba968-6488-4d42-86b3-0470ffcc95b9';
+ var OWNER = 'CustomBranding.Classic';
+ var MAX_DEPTH = 8;
+ var MAX_ELEMENTS = 200;
+ var allowedTags = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
+ var allowedStyles = ('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').split(' ');
+ var state = { debug: false, hosts: [], css: [] };
+
+ function log(message, data) {
+ if (state.debug && global.console && console.log) {
+ console.log('[CustomBranding Classic] ' + message, data || '');
+ }
+ }
+
+ function strings() {
+ var german = global._spPageContextInfo && Number(_spPageContextInfo.currentLanguage) === 1031;
+ return german ? {
+ loadError: 'Die CustomBranding-Konfiguration konnte nicht geladen werden.',
+ renderError: 'Das konfigurierte Branding konnte nicht dargestellt werden.'
+ } : {
+ loadError: 'The CustomBranding configuration could not be loaded.',
+ renderError: 'The configured branding could not be rendered.'
+ };
+ }
+
+ function siteUrl() {
+ return global._spPageContextInfo ? _spPageContextInfo.siteAbsoluteUrl : '';
+ }
+
+ function sanitizeUrl(value, allowMailto) {
+ var raw = String(value || '').replace(/^\s+|\s+$/g, '');
+ if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) { return null; }
+ var resolved = raw.toLowerCase().indexOf('~sitecollection') === 0
+ ? siteUrl().replace(/\/+$/, '') + raw.substring('~sitecollection'.length)
+ : raw;
+ var match = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
+ if (!match) { return resolved; }
+ var protocol = match[1].toLowerCase();
+ return protocol === 'http' || protocol === 'https' || (allowMailto && protocol === 'mailto') ? resolved : null;
+ }
+
+ function normalizeHosts(value) {
+ var result = [];
+ if (!Array.isArray(value)) { return result; }
+ for (var i = 0; i < value.length; i++) {
+ var host = String(value[i] || '').toLowerCase().replace(/^\s+|\s+$/g, '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
+ if (/^[a-z0-9.-]+(?::\d+)?$/.test(host) && result.indexOf(host) < 0) { result.push(host); }
+ }
+ return result;
+ }
+
+ function sanitizeCssUrl(value, hosts) {
+ var resolved = sanitizeUrl(value, false);
+ if (!resolved) { return null; }
+ var absolute = resolved.match(/^(https?):\/\/([^/]+)/i);
+ if (!absolute) { return resolved; }
+ var current = siteUrl().match(/^(https?):\/\/([^/]+)/i);
+ var protocol = absolute[1].toLowerCase();
+ var host = absolute[2].toLowerCase();
+ if (current && protocol === current[1].toLowerCase() && host === current[2].toLowerCase()) { return resolved; }
+ return protocol === 'https' && hosts.indexOf(host) >= 0 ? resolved : null;
+ }
+
+ function sanitizeStyle(name, value) {
+ var property = String(name || '').toLowerCase().replace(/^\s+|\s+$/g, '');
+ var styleValue = String(value || '').replace(/^\s+|\s+$/g, '');
+ if (allowedStyles.indexOf(property) < 0 || !styleValue || styleValue.length > 512) { return null; }
+ if (/[\u0000-\u001f\u007f]/.test(styleValue) || /(url\s*\(|expression\s*\(|javascript\s*:|@import|behavior\s*:|-moz-binding)/i.test(styleValue)) { return null; }
+ return styleValue;
+ }
+
+ function allowedAttribute(tag, name) {
+ if (['id', 'class', 'title', 'role', 'aria-label', 'aria-hidden', 'aria-current', 'aria-live'].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 createElement(config, depth, counter) {
+ if (!config || typeof config !== 'object' || depth > MAX_DEPTH || counter.value >= MAX_ELEMENTS) { return null; }
+ var tag = String(config.type || '').toLowerCase();
+ if (allowedTags.indexOf(tag) < 0) { return null; }
+ counter.value++;
+ var element = document.createElement(tag);
+ var attributes = config.attributes && typeof config.attributes === 'object' ? config.attributes : {};
+ var hasAlt = false;
+ for (var rawName in attributes) {
+ if (!Object.prototype.hasOwnProperty.call(attributes, rawName)) { continue; }
+ var name = String(rawName).toLowerCase();
+ var value = String(attributes[rawName] === undefined ? '' : attributes[rawName]).substring(0, 2048);
+ if (name.indexOf('on') === 0 || !allowedAttribute(tag, name)) { continue; }
+ if (name === 'href' || name === 'src') {
+ var safeUrl = sanitizeUrl(value, name === 'href');
+ if (safeUrl) { element.setAttribute(name, safeUrl); }
+ } else if (name === 'target') {
+ if (value === '_blank' || value === '_self') { element.setAttribute(name, value); }
+ } else if ((name === 'id' || name === 'class') && !/^[a-z0-9 _-]{1,256}$/i.test(value)) {
+ continue;
+ } else if ((name === 'width' || name === 'height') && !/^\d{1,4}$/.test(value)) {
+ continue;
+ } else {
+ element.setAttribute(name, value);
+ }
+ if (name === 'alt') { hasAlt = true; }
+ }
+ if (tag === 'img' && !hasAlt) { return null; }
+ if (tag === 'button') { element.setAttribute('type', 'button'); }
+ if (tag === 'a' && element.getAttribute('target') === '_blank') { element.setAttribute('rel', 'noopener noreferrer'); }
+
+ var styles = config.styles && typeof config.styles === 'object' ? config.styles : {};
+ for (var styleName in styles) {
+ if (!Object.prototype.hasOwnProperty.call(styles, styleName)) { continue; }
+ var safeStyle = sanitizeStyle(styleName, styles[styleName]);
+ if (safeStyle) { element.style.setProperty(String(styleName).toLowerCase(), safeStyle); }
+ }
+ if (config.content !== undefined && config.content !== null) {
+ element.appendChild(document.createTextNode(String(config.content).substring(0, 4000)));
+ }
+ if (tag !== 'img' && Array.isArray(config.children)) {
+ for (var i = 0; i < config.children.length; i++) {
+ var child = createElement(config.children[i], depth + 1, counter);
+ if (child) { element.appendChild(child); }
+ }
+ }
+ if ((tag === 'a' || tag === 'button') && !element.textContent && !element.getAttribute('aria-label')) { return null; }
+ return element;
+ }
+
+ function clear(element) {
+ while (element && element.firstChild) { element.removeChild(element.firstChild); }
+ }
+
+ function renderHost(id, parent, elements, beforeNode) {
+ var host = document.getElementById(id);
+ if (!host) {
+ host = document.createElement('div');
+ host.id = id;
+ host.setAttribute('data-custom-branding-owner', OWNER);
+ if (beforeNode) { parent.insertBefore(host, beforeNode); } else { parent.appendChild(host); }
+ }
+ clear(host);
+ var counter = { value: 0 };
+ for (var i = 0; i < elements.length; i++) {
+ var element = createElement(elements[i], 1, counter);
+ if (element) { host.appendChild(element); }
+ }
+ state.hosts.push(host);
+ }
+
+ function showStatus(message) {
+ var host = document.getElementById('CustomBrandingClassicTopHost') || document.createElement('div');
+ host.id = 'CustomBrandingClassicTopHost';
+ clear(host);
+ var status = document.createElement('div');
+ status.setAttribute('role', 'status');
+ status.textContent = message;
+ host.appendChild(status);
+ if (!host.parentNode) { document.body.insertBefore(host, document.body.firstChild); }
+ }
+
+ function loadCss(files, hosts) {
+ var seen = {};
+ for (var i = 0; i < files.length && i < 20; i++) {
+ var path = sanitizeCssUrl(files[i] && files[i].path, hosts);
+ var key = String(path || '').toLowerCase();
+ if (!path || seen[key]) { continue; }
+ seen[key] = true;
+ var link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = path;
+ link.media = files[i].media && /^[a-z0-9 (),.:\/-]{1,80}$/i.test(files[i].media)
+ ? files[i].media
+ : 'all';
+ link.setAttribute('data-custom-branding-owner', OWNER);
+ document.getElementsByTagName('head')[0].appendChild(link);
+ state.css.push(link);
+ }
+ }
+
+ function normalize(raw) {
+ raw = raw && typeof raw === 'object' ? raw : {};
+ return {
+ enabled: raw.enabled !== false,
+ debug: raw.debug === true,
+ allowedCssHosts: normalizeHosts(raw.allowedCssHosts),
+ cssfiles: Array.isArray(raw.cssfiles) ? raw.cssfiles : [],
+ top: raw.placeholdertop && Array.isArray(raw.placeholdertop.elements) ? raw.placeholdertop.elements : (Array.isArray(raw.elements) ? raw.elements : []),
+ bottom: raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements) ? raw.placeholderbottom.elements : []
+ };
+ }
+
+ function cleanup() {
+ for (var i = 0; i < state.hosts.length; i++) {
+ if (state.hosts[i].parentNode) { state.hosts[i].parentNode.removeChild(state.hosts[i]); }
+ }
+ for (var j = 0; j < state.css.length; j++) {
+ if (state.css[j].parentNode) { state.css[j].parentNode.removeChild(state.css[j]); }
+ }
+ state.hosts = [];
+ state.css = [];
+ }
+
+ function render(config) {
+ cleanup();
+ state.debug = config.debug;
+ if (!config.enabled) { return; }
+ loadCss(config.cssfiles, config.allowedCssHosts);
+ var titleRow = document.getElementById('s4-titlerow');
+ var topParent = titleRow && titleRow.parentNode ? titleRow.parentNode : document.body;
+ var topBefore = titleRow ? titleRow.nextSibling : document.body.firstChild;
+ renderHost('CustomBrandingClassicTopHost', topParent, config.top, topBefore);
+ var workspace = document.getElementById('s4-workspace') || document.body;
+ renderHost('CustomBrandingClassicBottomHost', workspace, config.bottom, null);
+ log('Branding rendered.');
+ }
+
+ function readConfiguration(callback, errorCallback) {
+ var url = siteUrl() + "/_api/site/UserCustomActions?$filter=ClientSideComponentId eq guid'" + COMPONENT_ID + "'&$select=ClientSideComponentProperties";
+ var request = new XMLHttpRequest();
+ request.open('GET', url, true);
+ request.setRequestHeader('Accept', 'application/json;odata=verbose');
+ request.onreadystatechange = function () {
+ if (request.readyState !== 4) { return; }
+ if (request.status < 200 || request.status >= 300) { errorCallback(); return; }
+ try {
+ var data = JSON.parse(request.responseText);
+ var actions = data && data.d && data.d.results ? data.d.results : [];
+ var serialized = actions.length && actions[0].ClientSideComponentProperties
+ ? actions[0].ClientSideComponentProperties
+ : '{}';
+ if (serialized.length > 100000) { throw new Error('Configuration exceeds the maximum size.'); }
+ var properties = JSON.parse(serialized);
+ callback(normalize(properties));
+ } catch (error) { errorCallback(error); }
+ };
+ request.send();
+ }
+
+ function init() {
+ readConfiguration(render, function (error) {
+ log('Configuration load failed.', error);
+ showStatus(strings().loadError);
+ });
+ }
+
+ function start() {
+ if (!global._spPageContextInfo) { global.setTimeout(start, 100); return; }
+ init();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', start);
+ } else {
+ global.setTimeout(start, 0);
+ }
+
+ global.CustomBrandingClassic = { init: init, reload: init, dispose: cleanup };
+}(window));
diff --git a/config/deploy-azure-storage.json b/config/deploy-azure-storage.json
deleted file mode 100644
index 782a3a6..0000000
--- a/config/deploy-azure-storage.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
- "workingDir": "./temp/deploy/",
- "account": "",
- "container": "custom-branding",
- "accessKey": ""
-}
\ No newline at end of file
diff --git a/config/package-solution.json b/config/package-solution.json
index 968912e..c230f50 100644
--- a/config/package-solution.json
+++ b/config/package-solution.json
@@ -3,22 +3,9 @@
"solution": {
"name": "custom-branding-client-side-solution",
"id": "03a7c4de-e031-4b83-a683-5ca5c364166e",
- "version": "1.0.4.0",
+ "version": "3.0.0.0",
"includeClientSideAssets": true,
- "skipFeatureDeployment": false,
- "features": [
- {
- "title": "Custom Branding extension registration",
- "description": "Registers the Custom Branding Application Customizer in the host web.",
- "id": "2eb6deaa-a4a0-40c5-9f15-6bdaff36094f",
- "version": "1.0.4.0",
- "assets": {
- "elementManifests": [
- "elements.xml"
- ]
- }
- }
- ]
+ "skipFeatureDeployment": true
},
"paths": {
"zippedPackage": "solution/custom-branding.sppkg"
diff --git a/config/serve.json b/config/serve.json
index 2bdd465..41ca43d 100644
--- a/config/serve.json
+++ b/config/serve.json
@@ -9,161 +9,43 @@
"035ba968-6488-4d42-86b3-0470ffcc95b9": {
"location": "ClientSideExtension.ApplicationCustomizer",
"properties": {
- "elements": [
- {
- "type": "div",
- "styles": {
- "background-color": "#0078d4",
- "color": "white",
- "padding": "15px 20px",
- "text-align": "center",
- "font-family": "Segoe UI, sans-serif",
- "font-size": "14px"
- },
- "content": "Willkommen auf unserem SharePoint Portal!"
- }
- ]
- }
- }
- }
- },
- "warning": {
- "pageUrl": "http://clshp001/",
- "customActions": {
- "035ba968-6488-4d42-86b3-0470ffcc95b9": {
- "location": "ClientSideExtension.ApplicationCustomizer",
- "properties": {
- "elements": [
- {
- "type": "div",
- "styles": {
- "background-color": "#d83b01",
- "color": "white",
- "padding": "15px",
- "text-align": "center",
- "font-weight": "bold"
- },
- "content": "⚠️ Achtung: Wartungsarbeiten am Wochenende"
- }
- ]
- }
- }
- }
- },
- "withLink": {
- "pageUrl": "http://clshp001/",
- "customActions": {
- "035ba968-6488-4d42-86b3-0470ffcc95b9": {
- "location": "ClientSideExtension.ApplicationCustomizer",
- "properties": {
- "elements": [
- {
- "type": "div",
- "styles": {
- "background-color": "#0078d4",
- "color": "white",
- "padding": "12px 20px",
- "text-align": "center"
- },
- "children": [
- {
- "type": "span",
- "content": "Wichtige Mitteilung: Systemwartung geplant. ",
- "styles": {
- "font-weight": "bold"
- }
+ "schemaVersion": 2,
+ "enabled": true,
+ "debug": true,
+ "allowedCssHosts": [],
+ "cssfiles": [],
+ "placeholdertop": {
+ "elements": [
+ {
+ "type": "section",
+ "attributes": {
+ "aria-label": "Portalhinweis"
},
- {
- "type": "a",
- "content": "Mehr Informationen",
- "attributes": {
- "href": "/sites/it/SitePages/Wartung.aspx",
- "target": "_blank"
+ "styles": {
+ "background-color": "#0078d4",
+ "color": "#ffffff",
+ "padding": "12px 20px",
+ "text-align": "center"
+ },
+ "children": [
+ {
+ "type": "strong",
+ "content": "Willkommen im SharePoint-Portal"
},
- "styles": {
- "color": "white",
- "text-decoration": "underline",
- "margin-left": "5px"
+ {
+ "type": "span",
+ "content": " – aktuelle Informationen finden Sie im Intranet."
}
- }
- ]
- }
- ]
- }
- }
- }
- },
- "gradient": {
- "pageUrl": "http://clshp001/",
- "customActions": {
- "035ba968-6488-4d42-86b3-0470ffcc95b9": {
- "location": "ClientSideExtension.ApplicationCustomizer",
- "properties": {
- "elements": [
- {
- "type": "div",
- "styles": {
- "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
- "color": "white",
- "padding": "20px"
- },
- "children": [
- {
- "type": "div",
- "styles": {
- "display": "flex",
- "justify-content": "space-between",
- "align-items": "center",
- "max-width": "1200px",
- "margin": "0 auto"
- },
- "children": [
- {
- "type": "div",
- "children": [
- {
- "type": "p",
- "styles": {
- "margin": "0",
- "font-size": "18px",
- "font-weight": "bold"
- },
- "content": "Neue Funktionen verfügbar!"
- },
- {
- "type": "p",
- "styles": {
- "margin": "5px 0 0 0",
- "font-size": "14px"
- },
- "content": "Entdecken Sie die neuesten Updates"
- }
- ]
- },
- {
- "type": "button",
- "content": "Mehr erfahren",
- "attributes": {
- "onclick": "window.location.href='/sites/news/SitePages/Updates.aspx'"
- },
- "styles": {
- "background-color": "white",
- "color": "#667eea",
- "border": "none",
- "padding": "10px 20px",
- "border-radius": "5px",
- "cursor": "pointer",
- "font-weight": "bold"
- }
- }
- ]
- }
- ]
- }
- ]
+ ]
+ }
+ ]
+ },
+ "placeholderbottom": {
+ "elements": []
+ }
}
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/deployment/add-custombranding.ps1 b/deployment/add-custombranding.ps1
index aef28d8..055b1fd 100644
--- a/deployment/add-custombranding.ps1
+++ b/deployment/add-custombranding.ps1
@@ -5,100 +5,190 @@ param(
[string]$CssPath = '',
+ [string[]]$AllowedCssHost = @(),
+
+ [switch]$EnableDebug,
+
+ [switch]$Disable,
+
+ [switch]$ResetConfiguration,
+
+ [string]$ClassicScriptUrl = '',
+
+ [switch]$RemoveClassicScriptLink,
+
[string]$Description = 'MSFT-Custom-Solution:CustomBranding'
)
-Add-PSSnapin Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue | Out-Null
+if (-not (Get-Command -Name Get-SPSite -ErrorAction SilentlyContinue)) {
+ if (Get-Module -ListAvailable -Name Microsoft.SharePoint.PowerShell) {
+ Import-Module Microsoft.SharePoint.PowerShell -DisableNameChecking
+ }
+ elseif (Get-PSSnapin -Registered -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) {
+ Add-PSSnapin Microsoft.SharePoint.PowerShell
+ }
+ else {
+ throw 'Die SharePoint PowerShell-Komponenten wurden nicht gefunden. Bitte in der SharePoint Management Shell ausfuehren.'
+ }
+}
-$componentId = '035ba968-6488-4d42-86b3-0470ffcc95b9'
-$location = 'ClientSideExtension.ApplicationCustomizer'
-$name = 'CustomBranding'
-$title = 'Custom Branding'
+$componentId = [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9'
+$componentIdText = $componentId.ToString().ToLowerInvariant()
+$componentLocation = 'ClientSideExtension.ApplicationCustomizer'
+$componentName = 'CustomBranding'
+$componentTitle = 'Custom Branding Application Customizer'
+$classicActionName = 'CustomBranding.Classic.ScriptLink'
-function Get-CustomBrandingPropertiesJson {
- param(
- [string]$Path
- )
-
- $config = @{
+function New-DefaultCustomBrandingConfiguration {
+ return @{
+ schemaVersion = 2
+ enabled = $true
+ debug = $false
+ allowedCssHosts = @()
cssfiles = @()
placeholdertop = @{ elements = @() }
placeholderbottom = @{ elements = @() }
}
+}
- if ($Path -and $Path.Trim().Length -gt 0) {
- $config.cssfiles += @{ path = $Path.Trim() }
+function ConvertTo-Hashtable {
+ param([object]$InputObject)
+ if ($null -eq $InputObject) { return $null }
+ if ($InputObject -is [System.Collections.IDictionary]) {
+ $result = @{}
+ foreach ($key in $InputObject.Keys) { $result[$key] = ConvertTo-Hashtable $InputObject[$key] }
+ return $result
}
+ if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
+ return @($InputObject | ForEach-Object { ConvertTo-Hashtable $_ })
+ }
+ if ($InputObject.PSObject -and $InputObject.PSObject.Properties.Count -gt 0 -and
+ -not ($InputObject -is [string]) -and -not ($InputObject.GetType().IsPrimitive)) {
+ $result = @{}
+ foreach ($property in $InputObject.PSObject.Properties) { $result[$property.Name] = ConvertTo-Hashtable $property.Value }
+ return $result
+ }
+ return $InputObject
+}
- return ($config | ConvertTo-Json -Depth 10 -Compress)
+function Get-ExistingConfiguration {
+ param([Microsoft.SharePoint.SPUserCustomAction]$Action)
+ if ($ResetConfiguration -or -not $Action -or [string]::IsNullOrWhiteSpace($Action.ClientSideComponentProperties)) {
+ return New-DefaultCustomBrandingConfiguration
+ }
+ try {
+ $parsed = $Action.ClientSideComponentProperties | ConvertFrom-Json
+ $configuration = ConvertTo-Hashtable $parsed
+ if (-not $configuration) { return New-DefaultCustomBrandingConfiguration }
+ return $configuration
+ }
+ catch {
+ Write-Warning 'Vorhandene ClientSideComponentProperties waren ungueltig und werden durch Standardwerte ersetzt.'
+ return New-DefaultCustomBrandingConfiguration
+ }
}
function Remove-WebScopedComponentActions {
- param(
- [Microsoft.SharePoint.SPSite]$CurrentSite,
- [string]$CurrentComponentId,
- [string]$CurrentLocation
- )
-
- $removedCount = 0
-
+ param([Microsoft.SharePoint.SPSite]$CurrentSite)
+ $removed = 0
foreach ($web in $CurrentSite.AllWebs) {
try {
- $webActions = @($web.UserCustomActions | Where-Object {
- $_.Location -eq $CurrentLocation -and
+ $actions = @($web.UserCustomActions | Where-Object {
+ $_.Location -eq $componentLocation -and
$_.ClientSideComponentId -and
- $_.ClientSideComponentId.ToString().ToLower() -eq $CurrentComponentId
+ $_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText
})
-
- foreach ($webAction in $webActions) {
- $web.UserCustomActions.Delete($webAction.Id)
- $removedCount++
- }
-
- if ($webActions.Count -gt 0) {
- $web.Update()
+ foreach ($action in $actions) {
+ $action.Delete()
+ $removed++
}
+ if ($actions.Count -gt 0) { $web.Update() }
}
finally {
$web.Dispose()
}
}
-
- return $removedCount
+ return $removed
}
$site = Get-SPSite -Identity $SiteUrl
try {
- $removedWebScopedActions = Remove-WebScopedComponentActions -CurrentSite $site -CurrentComponentId $componentId -CurrentLocation $location
+ $siteActions = @($site.UserCustomActions | Where-Object {
+ $_.Location -eq $componentLocation -and
+ $_.ClientSideComponentId -and
+ $_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText
+ })
+ $action = $siteActions | Select-Object -First 1
+ $configuration = Get-ExistingConfiguration -Action $action
- $existingAction = $site.UserCustomActions | Where-Object {
- $_.Location -eq $location -and $_.ClientSideComponentId -and $_.ClientSideComponentId.ToString().ToLower() -eq $componentId
- } | Select-Object -First 1
+ $configuration.schemaVersion = 2
+ if (-not $configuration.ContainsKey('placeholdertop')) { $configuration.placeholdertop = @{ elements = @() } }
+ if (-not $configuration.ContainsKey('placeholderbottom')) { $configuration.placeholderbottom = @{ elements = @() } }
+ if (-not $configuration.ContainsKey('cssfiles')) { $configuration.cssfiles = @() }
+ if (-not $configuration.ContainsKey('allowedCssHosts')) { $configuration.allowedCssHosts = @() }
+ if ($PSBoundParameters.ContainsKey('EnableDebug')) { $configuration.debug = [bool]$EnableDebug.IsPresent }
+ if ($PSBoundParameters.ContainsKey('Disable')) { $configuration.enabled = -not [bool]$Disable.IsPresent }
- if ($existingAction) {
- $action = $existingAction
- Write-Host 'Aktualisiere vorhandene site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow
+ if (-not [string]::IsNullOrWhiteSpace($CssPath)) {
+ $trimmedPath = $CssPath.Trim()
+ $existingPaths = @($configuration.cssfiles | ForEach-Object {
+ if ($_ -is [System.Collections.IDictionary]) { [string]$_['path'] } else { [string]$_.path }
+ })
+ if ($existingPaths -notcontains $trimmedPath) {
+ $configuration.cssfiles = @($configuration.cssfiles) + @(@{ path = $trimmedPath; media = 'all' })
+ }
+ }
+ if ($AllowedCssHost.Count -gt 0) {
+ $configuration.allowedCssHosts = @($AllowedCssHost | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique)
+ }
+
+ if (-not $action) {
+ $action = $site.UserCustomActions.Add()
+ Write-Host 'Erstelle zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow
}
else {
- $action = $site.UserCustomActions.Add()
- Write-Host 'Erstelle neue site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow
+ Write-Host 'Aktualisiere zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow
}
-
- $action.Name = $name
- $action.Title = $title
+ $action.Name = $componentName
+ $action.Title = $componentTitle
$action.Description = $Description
- $action.Location = $location
- $action.ClientSideComponentId = [Guid]$componentId
- $action.ClientSideComponentProperties = Get-CustomBrandingPropertiesJson -Path $CssPath
+ $action.Location = $componentLocation
+ $action.Sequence = 100
+ $action.ClientSideComponentId = $componentId
+ $action.ClientSideComponentProperties = [string]($configuration | ConvertTo-Json -Depth 20 -Compress)
$action.Update()
- Write-Host 'Custom Branding wurde site-scoped registriert.' -ForegroundColor Green
- Write-Host ('Entfernte web-scoped Eintraege: ' + $removedWebScopedActions)
- Write-Host ('Beschreibung: ' + $Description)
+ @($siteActions | Select-Object -Skip 1) | ForEach-Object {
+ Write-Host ('Entferne doppelte Site-Collection-Action: ' + $_.Id) -ForegroundColor Yellow
+ $_.Delete()
+ }
+ $removedWebActions = Remove-WebScopedComponentActions -CurrentSite $site
+
+ $classicActions = @($site.UserCustomActions | Where-Object {
+ $_.Location -eq 'ScriptLink' -and $_.Name -eq $classicActionName
+ })
+ if ($RemoveClassicScriptLink) {
+ foreach ($classicAction in $classicActions) { $classicAction.Delete() }
+ Write-Host 'Classic ScriptLink wurde entfernt.' -ForegroundColor Yellow
+ }
+ elseif (-not [string]::IsNullOrWhiteSpace($ClassicScriptUrl)) {
+ $classicAction = $classicActions | Select-Object -First 1
+ if (-not $classicAction) { $classicAction = $site.UserCustomActions.Add() }
+ $classicAction.Name = $classicActionName
+ $classicAction.Title = 'CustomBranding Classic Runtime'
+ $classicAction.Description = $Description
+ $classicAction.Location = 'ScriptLink'
+ $classicAction.Sequence = 101
+ $classicAction.ScriptSrc = $ClassicScriptUrl.Trim()
+ $classicAction.Update()
+ @($classicActions | Select-Object -Skip 1) | ForEach-Object { $_.Delete() }
+ Write-Host ('Classic ScriptLink: ' + $classicAction.ScriptSrc) -ForegroundColor Green
+ }
+
+ Write-Host 'CustomBranding wurde zentral fuer die Site Collection registriert.' -ForegroundColor Green
+ Write-Host ('Entfernte web-scoped Actions: ' + $removedWebActions)
Write-Host ('Properties: ' + $action.ClientSideComponentProperties)
}
finally {
- if ($site) {
- $site.Dispose()
- }
-}
\ No newline at end of file
+ if ($site) { $site.Dispose() }
+}
diff --git a/examples/custom-branding.css b/examples/custom-branding.css
new file mode 100644
index 0000000..6e85290
--- /dev/null
+++ b/examples/custom-branding.css
@@ -0,0 +1,44 @@
+.custom-branding-banner {
+ align-items: center;
+ background: #f3f2f1;
+ border-bottom: 1px solid #edebe9;
+ box-sizing: border-box;
+ color: #323130;
+ display: flex;
+ font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif;
+ font-size: 14px;
+ gap: 16px;
+ min-height: 40px;
+ padding: 8px 24px;
+}
+
+.custom-branding-link {
+ color: #005a9e;
+ text-decoration: none;
+}
+
+.custom-branding-link:hover {
+ color: #004578;
+ text-decoration: underline;
+}
+
+.custom-branding-link:focus-visible,
+.custom-branding-banner button:focus-visible {
+ outline: 2px solid #005a9e;
+ outline-offset: 2px;
+}
+
+@media screen and (max-width: 640px) {
+ .custom-branding-banner {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 12px;
+ }
+}
+
+@media (forced-colors: active) {
+ .custom-branding-banner {
+ border-bottom: 1px solid CanvasText;
+ }
+}
diff --git a/examples/custom-branding.example.json b/examples/custom-branding.example.json
new file mode 100644
index 0000000..db826e0
--- /dev/null
+++ b/examples/custom-branding.example.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "enabled": true,
+ "debug": false,
+ "allowedCssHosts": [],
+ "cssfiles": [
+ {
+ "path": "~sitecollection/SiteAssets/branding/custom-branding.css",
+ "media": "all"
+ }
+ ],
+ "placeholdertop": {
+ "elements": [
+ {
+ "type": "section",
+ "attributes": {
+ "class": "custom-branding-banner",
+ "role": "region",
+ "aria-label": "Portalhinweis"
+ },
+ "children": [
+ {
+ "type": "strong",
+ "content": "Willkommen im Portal"
+ },
+ {
+ "type": "a",
+ "content": "Zur Startseite",
+ "attributes": {
+ "href": "~sitecollection/SitePages/Home.aspx",
+ "class": "custom-branding-link"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "placeholderbottom": {
+ "elements": []
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 214c824..0221819 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "custom-branding",
- "version": "1.0.4",
+ "version": "3.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
diff --git a/package.json b/package.json
index a17f3f3..86ea43f 100644
--- a/package.json
+++ b/package.json
@@ -1,23 +1,23 @@
{
"name": "custom-branding",
- "version": "1.0.4",
+ "version": "3.0.0",
"private": true,
"main": "lib/index.js",
"engines": {
- "node": ">=0.10.0"
+ "node": ">=6.9.0 <9.0.0"
},
"scripts": {
- "build": "gulp bundle",
+ "build": "npm run build:classic && gulp bundle",
+ "build:classic": "powershell -NoProfile -ExecutionPolicy Bypass -File ./build-classic.ps1",
"clean": "gulp clean",
- "test": "gulp test",
- "package": "gulp clean && gulp bundle --ship && gulp package-solution --ship"
+ "test": "tsc -p tests/tsconfig.json && node tests/BrandingConfig.test.js && node tests/BrandingDomRenderer.test.js && node tests/BrandingCssLoader.test.js && node tests/validate-static-assets.js && powershell -NoProfile -ExecutionPolicy Bypass -File ./tests/validate-project.ps1",
+ "package": "npm test && npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship"
},
"dependencies": {
"@microsoft/sp-core-library": "~1.4.0",
"@microsoft/decorators": "~1.4.0",
"@types/webpack-env": "1.13.1",
"@types/es6-promise": "0.0.33",
- "@microsoft/sp-dialog": "~1.4.0",
"@microsoft/sp-application-base": "~1.4.0"
},
"devDependencies": {
@@ -27,6 +27,7 @@
"gulp": "~3.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
- "ajv": "~5.2.2"
+ "ajv": "~5.2.2",
+ "jsdom": "9.12.0"
}
}
diff --git a/sharepoint/assets/elements.xml b/sharepoint/assets/elements.xml
deleted file mode 100644
index 067af54..0000000
--- a/sharepoint/assets/elements.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
diff --git a/src/extensions/customBranding/BrandingConfig.ts b/src/extensions/customBranding/BrandingConfig.ts
new file mode 100644
index 0000000..5c36196
--- /dev/null
+++ b/src/extensions/customBranding/BrandingConfig.ts
@@ -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;
+}
+
diff --git a/src/extensions/customBranding/BrandingCssLoader.ts b/src/extensions/customBranding/BrandingCssLoader.ts
new file mode 100644
index 0000000..a1914a6
--- /dev/null
+++ b/src/extensions/customBranding/BrandingCssLoader.ts
@@ -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 = 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;
+ }
+}
diff --git a/src/extensions/customBranding/BrandingDomRenderer.ts b/src/extensions/customBranding/BrandingDomRenderer.ts
new file mode 100644
index 0000000..6ac26c8
--- /dev/null
+++ b/src/extensions/customBranding/BrandingDomRenderer.ts
@@ -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;
+ }
+}
+
diff --git a/src/extensions/customBranding/BrandingTypes.ts b/src/extensions/customBranding/BrandingTypes.ts
new file mode 100644
index 0000000..e02317c
--- /dev/null
+++ b/src/extensions/customBranding/BrandingTypes.ts
@@ -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[];
+}
+
diff --git a/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts b/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts
index 9ddb728..c005356 100644
--- a/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts
+++ b/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts
@@ -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 {
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 {
- 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 } = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": '''
- };
-
- 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';
diff --git a/src/extensions/customBranding/loc/de-de.js b/src/extensions/customBranding/loc/de-de.js
new file mode 100644
index 0000000..2c5d985
--- /dev/null
+++ b/src/extensions/customBranding/loc/de-de.js
@@ -0,0 +1,7 @@
+define([], function() {
+ return {
+ "Title": "CustomBrandingApplicationCustomizer",
+ "Initialized": "CustomBranding wurde initialisiert",
+ "RenderError": "Das konfigurierte Branding konnte nicht dargestellt werden."
+ };
+});
diff --git a/src/extensions/customBranding/loc/en-us.js b/src/extensions/customBranding/loc/en-us.js
index 23031a1..de1e4ac 100644
--- a/src/extensions/customBranding/loc/en-us.js
+++ b/src/extensions/customBranding/loc/en-us.js
@@ -1,5 +1,7 @@
define([], function() {
return {
- "Title": "CustomBrandingApplicationCustomizer"
+ "Title": "CustomBrandingApplicationCustomizer",
+ "Initialized": "CustomBranding initialized",
+ "RenderError": "The configured branding could not be rendered."
}
-});
\ No newline at end of file
+});
diff --git a/src/extensions/customBranding/loc/myStrings.d.ts b/src/extensions/customBranding/loc/myStrings.d.ts
index 2083380..316d002 100644
--- a/src/extensions/customBranding/loc/myStrings.d.ts
+++ b/src/extensions/customBranding/loc/myStrings.d.ts
@@ -1,5 +1,7 @@
declare interface ICustomBrandingApplicationCustomizerStrings {
Title: string;
+ Initialized: string;
+ RenderError: string;
}
declare module 'CustomBrandingApplicationCustomizerStrings' {
diff --git a/src/index.ts b/src/index.ts
index fb81db1..bd886b0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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';
diff --git a/tests/BrandingConfig.test.js b/tests/BrandingConfig.test.js
new file mode 100644
index 0000000..591d816
--- /dev/null
+++ b/tests/BrandingConfig.test.js
@@ -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.');
diff --git a/tests/BrandingCssLoader.test.js b/tests/BrandingCssLoader.test.js
new file mode 100644
index 0000000..411900a
--- /dev/null
+++ b/tests/BrandingCssLoader.test.js
@@ -0,0 +1,56 @@
+'use strict';
+
+var jsdom = require('jsdom');
+var document = jsdom.jsdom('', {
+ 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.');
diff --git a/tests/BrandingDomRenderer.test.js b/tests/BrandingDomRenderer.test.js
new file mode 100644
index 0000000..daa45c8
--- /dev/null
+++ b/tests/BrandingDomRenderer.test.js
@@ -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.');
diff --git a/tests/tsconfig.json b/tests/tsconfig.json
new file mode 100644
index 0000000..74cd7bb
--- /dev/null
+++ b/tests/tsconfig.json
@@ -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"
+ ]
+}
diff --git a/tests/validate-project.ps1 b/tests/validate-project.ps1
new file mode 100644
index 0000000..fa95bac
--- /dev/null
+++ b/tests/validate-project.ps1
@@ -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."
diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js
new file mode 100644
index 0000000..c1af3b0
--- /dev/null
+++ b/tests/validate-static-assets.js
@@ -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.');