feat: Add custom branding functionality with CSS and JSON configuration

- Introduced custom branding CSS styles in `custom-branding.css`.
- Created example JSON configuration for custom branding in `custom-branding.example.json`.
- Implemented branding configuration logic in `BrandingConfig.ts` to normalize and validate branding settings.
- Developed CSS loader to manage loading and unloading of custom stylesheets in `BrandingCssLoader.ts`.
- Added DOM rendering capabilities for branding elements in `BrandingDomRenderer.ts`.
- Defined types and interfaces for branding elements and configurations in `BrandingTypes.ts`.
- Included localization support for German in `de-de.js`.
- Added unit tests for branding configuration, CSS loader, and DOM renderer.
- Validated project structure and static assets with new validation scripts.
This commit is contained in:
Torsten Brendgen
2026-07-20 22:55:47 +02:00
parent b99ac31f4d
commit 490e9adbd8
29 changed files with 1809 additions and 588 deletions

196
README.md
View File

@@ -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 ## Konfiguration
Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCustomAction gespeichert und zentral ueber PortalSettings gepflegt: Das aktuelle Konfigurationsschema hat die Version 2:
```json ```json
{ {
"cssfiles": [], "schemaVersion": 2,
"enabled": true,
"debug": false,
"allowedCssHosts": [],
"cssfiles": [
{
"path": "~sitecollection/SiteAssets/branding/custom-branding.css",
"media": "all"
}
],
"placeholdertop": { "placeholdertop": {
"elements": [] "elements": [
{
"type": "section",
"attributes": {
"class": "custom-branding-banner",
"role": "region",
"aria-label": "Portalhinweis"
},
"children": [
{
"type": "strong",
"content": "Willkommen im Portal"
}
]
}
]
}, },
"placeholderbottom": { "placeholderbottom": {
"elements": [] "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 ```powershell
npm install npm run build:classic
npm run package
``` ```
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. 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`.
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.
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.

120
ToDo.md Normal file
View File

@@ -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.

25
build-classic.ps1 Normal file
View File

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

View File

@@ -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()
```

View File

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

View File

@@ -1,7 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "custom-branding",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@@ -3,22 +3,9 @@
"solution": { "solution": {
"name": "custom-branding-client-side-solution", "name": "custom-branding-client-side-solution",
"id": "03a7c4de-e031-4b83-a683-5ca5c364166e", "id": "03a7c4de-e031-4b83-a683-5ca5c364166e",
"version": "1.0.4.0", "version": "3.0.0.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": false, "skipFeatureDeployment": true
"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"
]
}
}
]
}, },
"paths": { "paths": {
"zippedPackage": "solution/custom-branding.sppkg" "zippedPackage": "solution/custom-branding.sppkg"

View File

@@ -9,158 +9,40 @@
"035ba968-6488-4d42-86b3-0470ffcc95b9": { "035ba968-6488-4d42-86b3-0470ffcc95b9": {
"location": "ClientSideExtension.ApplicationCustomizer", "location": "ClientSideExtension.ApplicationCustomizer",
"properties": { "properties": {
"elements": [ "schemaVersion": 2,
{ "enabled": true,
"type": "div", "debug": true,
"styles": { "allowedCssHosts": [],
"background-color": "#0078d4", "cssfiles": [],
"color": "white", "placeholdertop": {
"padding": "15px 20px", "elements": [
"text-align": "center", {
"font-family": "Segoe UI, sans-serif", "type": "section",
"font-size": "14px" "attributes": {
}, "aria-label": "Portalhinweis"
"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"
}
}, },
{ "styles": {
"type": "a", "background-color": "#0078d4",
"content": "Mehr Informationen", "color": "#ffffff",
"attributes": { "padding": "12px 20px",
"href": "/sites/it/SitePages/Wartung.aspx", "text-align": "center"
"target": "_blank" },
"children": [
{
"type": "strong",
"content": "Willkommen im SharePoint-Portal"
}, },
"styles": { {
"color": "white", "type": "span",
"text-decoration": "underline", "content": " aktuelle Informationen finden Sie im Intranet."
"margin-left": "5px"
} }
} ]
] }
} ]
] },
} "placeholderbottom": {
} "elements": []
} }
},
"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"
}
}
]
}
]
}
]
} }
} }
} }

View File

@@ -5,100 +5,190 @@ param(
[string]$CssPath = '', [string]$CssPath = '',
[string[]]$AllowedCssHost = @(),
[switch]$EnableDebug,
[switch]$Disable,
[switch]$ResetConfiguration,
[string]$ClassicScriptUrl = '',
[switch]$RemoveClassicScriptLink,
[string]$Description = 'MSFT-Custom-Solution:CustomBranding' [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' $componentId = [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9'
$location = 'ClientSideExtension.ApplicationCustomizer' $componentIdText = $componentId.ToString().ToLowerInvariant()
$name = 'CustomBranding' $componentLocation = 'ClientSideExtension.ApplicationCustomizer'
$title = 'Custom Branding' $componentName = 'CustomBranding'
$componentTitle = 'Custom Branding Application Customizer'
$classicActionName = 'CustomBranding.Classic.ScriptLink'
function Get-CustomBrandingPropertiesJson { function New-DefaultCustomBrandingConfiguration {
param( return @{
[string]$Path schemaVersion = 2
) enabled = $true
debug = $false
$config = @{ allowedCssHosts = @()
cssfiles = @() cssfiles = @()
placeholdertop = @{ elements = @() } placeholdertop = @{ elements = @() }
placeholderbottom = @{ elements = @() } placeholderbottom = @{ elements = @() }
} }
}
if ($Path -and $Path.Trim().Length -gt 0) { function ConvertTo-Hashtable {
$config.cssfiles += @{ path = $Path.Trim() } 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 { function Remove-WebScopedComponentActions {
param( param([Microsoft.SharePoint.SPSite]$CurrentSite)
[Microsoft.SharePoint.SPSite]$CurrentSite, $removed = 0
[string]$CurrentComponentId,
[string]$CurrentLocation
)
$removedCount = 0
foreach ($web in $CurrentSite.AllWebs) { foreach ($web in $CurrentSite.AllWebs) {
try { try {
$webActions = @($web.UserCustomActions | Where-Object { $actions = @($web.UserCustomActions | Where-Object {
$_.Location -eq $CurrentLocation -and $_.Location -eq $componentLocation -and
$_.ClientSideComponentId -and $_.ClientSideComponentId -and
$_.ClientSideComponentId.ToString().ToLower() -eq $CurrentComponentId $_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText
}) })
foreach ($action in $actions) {
foreach ($webAction in $webActions) { $action.Delete()
$web.UserCustomActions.Delete($webAction.Id) $removed++
$removedCount++
}
if ($webActions.Count -gt 0) {
$web.Update()
} }
if ($actions.Count -gt 0) { $web.Update() }
} }
finally { finally {
$web.Dispose() $web.Dispose()
} }
} }
return $removed
return $removedCount
} }
$site = Get-SPSite -Identity $SiteUrl $site = Get-SPSite -Identity $SiteUrl
try { 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 { $configuration.schemaVersion = 2
$_.Location -eq $location -and $_.ClientSideComponentId -and $_.ClientSideComponentId.ToString().ToLower() -eq $componentId if (-not $configuration.ContainsKey('placeholdertop')) { $configuration.placeholdertop = @{ elements = @() } }
} | Select-Object -First 1 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) { if (-not [string]::IsNullOrWhiteSpace($CssPath)) {
$action = $existingAction $trimmedPath = $CssPath.Trim()
Write-Host 'Aktualisiere vorhandene site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow $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 { else {
$action = $site.UserCustomActions.Add() Write-Host 'Aktualisiere zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow
Write-Host 'Erstelle neue site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow
} }
$action.Name = $componentName
$action.Name = $name $action.Title = $componentTitle
$action.Title = $title
$action.Description = $Description $action.Description = $Description
$action.Location = $location $action.Location = $componentLocation
$action.ClientSideComponentId = [Guid]$componentId $action.Sequence = 100
$action.ClientSideComponentProperties = Get-CustomBrandingPropertiesJson -Path $CssPath $action.ClientSideComponentId = $componentId
$action.ClientSideComponentProperties = [string]($configuration | ConvertTo-Json -Depth 20 -Compress)
$action.Update() $action.Update()
Write-Host 'Custom Branding wurde site-scoped registriert.' -ForegroundColor Green @($siteActions | Select-Object -Skip 1) | ForEach-Object {
Write-Host ('Entfernte web-scoped Eintraege: ' + $removedWebScopedActions) Write-Host ('Entferne doppelte Site-Collection-Action: ' + $_.Id) -ForegroundColor Yellow
Write-Host ('Beschreibung: ' + $Description) $_.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) Write-Host ('Properties: ' + $action.ClientSideComponentProperties)
} }
finally { finally {
if ($site) { if ($site) { $site.Dispose() }
$site.Dispose()
}
} }

View File

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

View File

@@ -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": []
}
}

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "custom-branding", "name": "custom-branding",
"version": "1.0.4", "version": "3.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@@ -1,23 +1,23 @@
{ {
"name": "custom-branding", "name": "custom-branding",
"version": "1.0.4", "version": "3.0.0",
"private": true, "private": true,
"main": "lib/index.js", "main": "lib/index.js",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=6.9.0 <9.0.0"
}, },
"scripts": { "scripts": {
"build": "gulp bundle", "build": "npm run build:classic && gulp bundle",
"build:classic": "powershell -NoProfile -ExecutionPolicy Bypass -File ./build-classic.ps1",
"clean": "gulp clean", "clean": "gulp clean",
"test": "gulp test", "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": "gulp clean && gulp bundle --ship && gulp package-solution --ship" "package": "npm test && npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-core-library": "~1.4.0", "@microsoft/sp-core-library": "~1.4.0",
"@microsoft/decorators": "~1.4.0", "@microsoft/decorators": "~1.4.0",
"@types/webpack-env": "1.13.1", "@types/webpack-env": "1.13.1",
"@types/es6-promise": "0.0.33", "@types/es6-promise": "0.0.33",
"@microsoft/sp-dialog": "~1.4.0",
"@microsoft/sp-application-base": "~1.4.0" "@microsoft/sp-application-base": "~1.4.0"
}, },
"devDependencies": { "devDependencies": {
@@ -27,6 +27,7 @@
"gulp": "~3.9.1", "gulp": "~3.9.1",
"@types/chai": "3.4.34", "@types/chai": "3.4.34",
"@types/mocha": "2.2.38", "@types/mocha": "2.2.38",
"ajv": "~5.2.2" "ajv": "~5.2.2",
"jsdom": "9.12.0"
} }
} }

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<CustomAction
Title="Custom Branding Application Customizer"
Location="ClientSideExtension.ApplicationCustomizer"
ClientSideComponentId="035ba968-6488-4d42-86b3-0470ffcc95b9"
ClientSideComponentProperties="{&quot;cssfiles&quot;:[],&quot;placeholdertop&quot;:{&quot;elements&quot;:[]},&quot;placeholderbottom&quot;:{&quot;elements&quot;:[]}}" />
</Elements>

View File

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

View File

@@ -0,0 +1,97 @@
import { ICssFile } from './BrandingTypes';
interface ISharedCssEntry {
element: HTMLLinkElement;
references: number;
owned: boolean;
}
export class BrandingCssLoader {
private static _registry: { [url: string]: ISharedCssEntry } = {};
private _loadedKeys: string[] = [];
constructor(private ownerId: string, private debugLog: (message: string, data?: any) => void) { } // tslint:disable-line:no-any
public load(files: ICssFile[]): void {
for (let i: number = 0; i < files.length; i++) {
this.loadOne(files[i]);
}
}
public dispose(): void {
for (let i: number = 0; i < this._loadedKeys.length; i++) {
const key: string = this._loadedKeys[i];
const entry: ISharedCssEntry = BrandingCssLoader._registry[key];
if (!entry) { continue; }
entry.references--;
if (entry.references <= 0) {
if (entry.owned && entry.element.parentNode) {
entry.element.parentNode.removeChild(entry.element);
}
delete BrandingCssLoader._registry[key];
}
}
this._loadedKeys = [];
}
private loadOne(file: ICssFile): void {
const absoluteUrl: string = this.toAbsoluteUrl(file.path);
const key: string = absoluteUrl.toLowerCase();
if (this._loadedKeys.indexOf(key) >= 0) { return; }
let entry: ISharedCssEntry = BrandingCssLoader._registry[key];
if (entry) {
entry.references++;
this._loadedKeys.push(key);
return;
}
const existing: HTMLLinkElement = this.findExistingStylesheet(key);
if (existing) {
BrandingCssLoader._registry[key] = { element: existing, references: 1, owned: false };
this._loadedKeys.push(key);
return;
}
const link: HTMLLinkElement = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = absoluteUrl;
link.media = file.media || 'all';
link.setAttribute('data-custom-branding-owner', this.ownerId);
link.setAttribute('data-custom-branding-url', key);
const timeout: number = window.setTimeout((): void => {
this.debugLog('Stylesheet load timed out.', { path: file.path });
}, 10000);
link.onload = (): void => {
window.clearTimeout(timeout);
this.debugLog('Stylesheet loaded.', { path: file.path });
};
link.onerror = (): void => {
window.clearTimeout(timeout);
this.debugLog('Stylesheet failed to load.', { path: file.path });
};
document.head.appendChild(link);
entry = { element: link, references: 1, owned: true };
BrandingCssLoader._registry[key] = entry;
this._loadedKeys.push(key);
}
private toAbsoluteUrl(path: string): string {
const anchor: HTMLAnchorElement = document.createElement('a');
anchor.href = path;
return anchor.href;
}
private findExistingStylesheet(key: string): HTMLLinkElement | undefined {
const links: NodeListOf<HTMLLinkElement> = document.getElementsByTagName('link');
for (let i: number = 0; i < links.length; i++) {
if (String(links[i].rel || '').toLowerCase() === 'stylesheet'
&& String(links[i].href || '').toLowerCase() === key) {
return links[i];
}
}
return undefined;
}
}

View File

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

View File

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

View File

@@ -1,373 +1,175 @@
import { override } from '@microsoft/decorators'; // tslint:disable:no-any max-line-length
// Legacy SPFx 1.4 implementation; incremental modernization is tracked separately. import { override } from '@microsoft/decorators';
// tslint:disable:max-line-length no-consecutive-blank-lines no-function-expression no-trailing-whitespace typedef
import { Log } from '@microsoft/sp-core-library'; import { Log } from '@microsoft/sp-core-library';
import { import {
BaseApplicationCustomizer, BaseApplicationCustomizer,
PlaceholderContent, PlaceholderContent,
PlaceholderName PlaceholderName
} from '@microsoft/sp-application-base'; } 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 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 export default class CustomBrandingApplicationCustomizer
extends BaseApplicationCustomizer<ICustomBrandingApplicationCustomizerProperties> { extends BaseApplicationCustomizer<ICustomBrandingApplicationCustomizerProperties> {
private _topPlaceholder: PlaceholderContent | undefined; private _topPlaceholder: PlaceholderContent | undefined;
private _bottomPlaceholder: 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 @override
public onInit(): Promise<void> { public onInit(): Promise<void> {
Log.info(LOG_SOURCE, 'Initialized CustomBrandingApplicationCustomizer'); const result: IBrandingNormalizationResult = normalizeBrandingConfig(
this.properties,
this.context.pageContext.site.absoluteUrl
);
this._config = result.config;
this.debug(strings.Initialized + ' 3.0.0.', {
schemaVersion: this._config.schemaVersion,
cssFileCount: this._config.cssfiles.length,
warningCount: result.warnings.length
});
for (let i: number = 0; i < result.warnings.length; i++) {
this.debug('Configuration warning: ' + result.warnings[i]);
}
// CSS-Dateien laden if (!this._config.enabled) {
this._loadCssFiles(); this.debug('CustomBranding is disabled by configuration.');
return Promise.resolve();
// Auf Placeholder-ÃÆÃ†â€™ÃƒÂ¢Ã¢â€šÂ¬Ã…¾nderungen reagieren }
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceHolders);
// Initial rendern
this._renderPlaceHolders();
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(); return Promise.resolve();
} }
/** private _renderPlaceholders(): void {
* LÃÆÃ†â€™Ãƒâ€šÃ¤dt CSS-Dateien aus der Konfiguration if (this._isDisposed || !this._config || !this._config.enabled) { return; }
*/
private _loadCssFiles(): void {
if (this.properties && this.properties.cssfiles && Array.isArray(this.properties.cssfiles)) {
console.log('CustomBranding: Loading CSS files...');
for (let i = 0; i < this.properties.cssfiles.length; i++) { if (!this._topPlaceholder) {
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) {
this._topPlaceholder = this.context.placeholderProvider.tryCreateContent( this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(
PlaceholderName.Top, PlaceholderName.Top,
{ onDispose: this._onDispose } { onDispose: this._onTopPlaceholderDisposed }
); );
}
if (!this._bottomPlaceholder) {
this._bottomPlaceholder = this.context.placeholderProvider.tryCreateContent( this._bottomPlaceholder = this.context.placeholderProvider.tryCreateContent(
PlaceholderName.Bottom, PlaceholderName.Bottom,
{ onDispose: this._onDispose } { onDispose: this._onBottomPlaceholderDisposed }
); );
}
// Falls Placeholder nicht verfÃÆÃ†â€™Ãƒâ€šÃ¼gbar, abbrechen if (this._topPlaceholder && this._topPlaceholder.domElement) {
if (!this._topPlaceholder) { this._topPlaceholder.domElement.id = 'CustomHeader';
console.error('CustomBranding: Top placeholder not found'); this._topHost = this.getOrCreateOwnedHost(
return; this._topPlaceholder.domElement,
} 'CustomBrandingTopHost'
);
// Falls Placeholder nicht verfÃÆÃ†â€™Ãƒâ€šÃ¼gbar, abbrechen this.renderSafely(this._topHost, this._config.placeholdertop.elements);
if (!this._bottomPlaceholder) { }
console.error('CustomBranding: Bottom placeholder not found'); if (this._bottomPlaceholder && this._bottomPlaceholder.domElement) {
return; this._bottomPlaceholder.domElement.id = 'CustomFooter';
} this._bottomHost = this.getOrCreateOwnedHost(
this._bottomPlaceholder.domElement,
if (this._topPlaceholder.domElement && this._bottomPlaceholder.domElement) { 'CustomBrandingBottomHost'
// Container erstellen mit hoher PrioritÃÆÃ†â€™Ãƒâ€šÃ¤t );
this.renderPlaceHolder(this._topPlaceholder.domElement, this._bottomPlaceholder.domElement); this.renderSafely(this._bottomHost, this._config.placeholderbottom.elements);
console.log('CustomBranding: HTML injected successfully');
}
} }
} }
private renderPlaceHolder(topcontainer: HTMLElement, bottomcontainer: HTMLElement) { private getOrCreateOwnedHost(parent: HTMLElement, id: string): HTMLElement {
let host: HTMLElement = parent.querySelector('#' + id) as HTMLElement;
if (!this.properties) { if (!host) {
console.log('CustomBranding: No properties provided'); host = document.createElement('div');
return; 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 { try {
// Top-Konfiguration unverÃÆÃ†â€™Ãƒâ€šÃ¤ndert ÃÆÃ†â€™Ãƒâ€šÃ¼bernehmen this._renderer.render(host, elements);
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;
} catch (error) { } 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);
} }
} }
private _onTopPlaceholderDisposed = (): void => {
/** this.removeHost(this._topHost);
* Kompiliert die JSON-Konfiguration zu HTML this._topHost = undefined;
*/ this._topPlaceholder = undefined;
private _compileToHtml(config: IBrandingConfig): { top: string; bottom: string } { this.debug('Top placeholder disposed; waiting for a new placeholder.');
// 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 _onBottomPlaceholderDisposed = (): void => {
* Erstellt HTML fÃÆÃ†â€™Ãƒâ€šÃ¼r ein einzelnes Element this.removeHost(this._bottomHost);
*/ this._bottomHost = undefined;
private _createElement(element: IBrandingElement): string { this._bottomPlaceholder = undefined;
const tag = element.type || 'div'; this.debug('Bottom placeholder disposed; waiting for a new placeholder.');
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 _isSiteAdmin(): boolean { @override
return this.context.pageContext.legacyPageContext.isSiteAdmin === true; 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 { private removeHost(host: HTMLElement | undefined): void {
const siteUrl = this.context.pageContext.site.absoluteUrl; if (host && host.parentNode) {
host.parentNode.removeChild(host);
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 debug(message: string, data?: any): void {
* Escaped HTML-Zeichen fÃÆÃ†â€™Ãƒâ€šÃ¼r Sicherheit if (!this._config || !this._config.debug) { return; }
*/ Log.info(LOG_SOURCE, message);
private _escapeHtml(text: string): string { if (window.console && window.console.log) {
const map: { [key: string]: string } = { console.log('[CustomBranding] ' + message, data || '');
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, function (m) {
return map[m];
});
}
private _onDispose(): void {
console.log('CustomBrandingApplicationCustomizer._onDispose()');
// CSS-Dateien beim Dispose entfernen
const cssLinks = document.querySelectorAll('link[data-custom-branding="true"]');
for (let i = 0; i < cssLinks.length; i++) {
const link = cssLinks[i];
if (link.parentNode) {
link.parentNode.removeChild(link);
}
} }
} }
} }
export {
IBrandingElement,
ICssFile,
ICustomBrandingApplicationCustomizerProperties,
IPlaceholderConfig
} from './BrandingTypes';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

17
tests/tsconfig.json Normal file
View File

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

View File

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

View File

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