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:
196
README.md
196
README.md
@@ -1,20 +1,110 @@
|
||||
# Custom Branding
|
||||
# CustomBranding
|
||||
|
||||
Custom Branding ist ein SPFx-1.4.1-Application-Customizer fuer SharePoint Server Subscription Edition. Die Solution laedt zentral konfigurierte Stylesheets und kann strukturierte Inhalte in den oberen und unteren SharePoint-Placeholder rendern.
|
||||
CustomBranding 3.0 ist ein zentraler SPFx-1.4.1-Application-Customizer für SharePoint Server Subscription Edition. Die Solution lädt freigegebene Stylesheets und rendert eine kontrollierte Komponentenstruktur im oberen oder unteren SharePoint-Placeholder. Moderne und klassische Seiten verwenden dieselben `ClientSideComponentProperties`.
|
||||
|
||||
## Version
|
||||
Die Konfiguration liegt in genau einer `SPSite.UserCustomAction` pro Site Collection. Es werden weder eine versteckte Liste noch ein Property Bag benötigt. Dadurch gilt das Branding automatisch für das Root Web, vorhandene Subwebs und später angelegte Subwebs.
|
||||
|
||||
`1.0.4.0`
|
||||
## Architektur
|
||||
|
||||
```text
|
||||
App Catalog
|
||||
└── zentral bereitgestelltes SPFx-Bundle
|
||||
|
||||
Site Collection
|
||||
├── SPSite.UserCustomAction mit ClientSideComponentProperties
|
||||
├── moderne Seiten: SPFx Application Customizer
|
||||
└── klassische Seiten: optionaler SPSite ScriptLink
|
||||
|
||||
CustomHeader
|
||||
├── CustomBrandingTopHost
|
||||
└── MegaMenuHost (wird nicht verändert)
|
||||
|
||||
CustomFooter
|
||||
└── CustomBrandingBottomHost
|
||||
```
|
||||
|
||||
CustomBranding verändert nur seine eigenen Host-Elemente. Das MegaMenu und andere Erweiterungen im selben Placeholder bleiben bei Navigation und erneutem Rendern erhalten.
|
||||
|
||||
## Voraussetzungen und Build
|
||||
|
||||
- SharePoint Server Subscription Edition
|
||||
- SPFx 1.4.1
|
||||
- Node.js 8.17.0
|
||||
- npm 6.13.4
|
||||
- lokale Gulp-Version 3.9.1
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm test
|
||||
npm run package
|
||||
```
|
||||
|
||||
`npm run package` führt zuerst die Tests und den Classic-Build aus. Das SharePoint-Paket entsteht unter `sharepoint/solution/custom-branding.sppkg`. Die Classic-Dateien liegen anschließend unter `classic/dist`.
|
||||
|
||||
Die alte SPFx-Toolchain lässt sich mit aktuellen Node-Versionen nicht zuverlässig paketieren. Der abschließende Ship-Build muss deshalb mit Node.js 8.17.0 erfolgen.
|
||||
|
||||
## Installation und zentrale Registrierung
|
||||
|
||||
1. `custom-branding.sppkg` im App Catalog hochladen oder ersetzen und zentral bereitstellen.
|
||||
2. Das Skript in der SharePoint Management Shell ausführen.
|
||||
3. Eine moderne Seite mit `Strg+F5` neu laden.
|
||||
|
||||
```powershell
|
||||
.\deployment\add-custombranding.ps1 `
|
||||
-SiteUrl 'http://clshp001/sites/portal' `
|
||||
-CssPath '~sitecollection/SiteAssets/branding/custom-branding.css'
|
||||
```
|
||||
|
||||
Das Skript arbeitet idempotent: Es erhält vorhandene Properties, entfernt doppelte oder alte web-scoped Registrierungen und erzeugt genau eine site-scoped Action. Ein bewusstes Zurücksetzen erfolgt nur mit `-ResetConfiguration`.
|
||||
|
||||
Weitere Optionen:
|
||||
|
||||
```powershell
|
||||
# Debug-Ausgaben einschalten und einen externen HTTPS-CSS-Host freigeben
|
||||
.\deployment\add-custombranding.ps1 `
|
||||
-SiteUrl 'http://clshp001/sites/portal' `
|
||||
-EnableDebug `
|
||||
-AllowedCssHost 'cdn.example.org'
|
||||
|
||||
# Extension deaktivieren, Konfiguration aber erhalten
|
||||
.\deployment\add-custombranding.ps1 `
|
||||
-SiteUrl 'http://clshp001/sites/portal' `
|
||||
-Disable
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCustomAction gespeichert und zentral ueber PortalSettings gepflegt:
|
||||
Das aktuelle Konfigurationsschema hat die Version 2:
|
||||
|
||||
```json
|
||||
{
|
||||
"cssfiles": [],
|
||||
"schemaVersion": 2,
|
||||
"enabled": true,
|
||||
"debug": false,
|
||||
"allowedCssHosts": [],
|
||||
"cssfiles": [
|
||||
{
|
||||
"path": "~sitecollection/SiteAssets/branding/custom-branding.css",
|
||||
"media": "all"
|
||||
}
|
||||
],
|
||||
"placeholdertop": {
|
||||
"elements": []
|
||||
"elements": [
|
||||
{
|
||||
"type": "section",
|
||||
"attributes": {
|
||||
"class": "custom-branding-banner",
|
||||
"role": "region",
|
||||
"aria-label": "Portalhinweis"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "strong",
|
||||
"content": "Willkommen im Portal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"placeholderbottom": {
|
||||
"elements": []
|
||||
@@ -22,22 +112,92 @@ Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCu
|
||||
}
|
||||
```
|
||||
|
||||
## Build
|
||||
Bestehende 1.x-Konfigurationen mit einem Root-Array `elements` werden weiterhin als Top-Inhalt gelesen. Unbekannte Properties ignoriert die Runtime. Das vollständige Beispiel liegt unter `examples/custom-branding.example.json`.
|
||||
|
||||
Die Legacy-SPFx-Toolchain benoetigt Node.js 8.17.0:
|
||||
### Sicherheitsgrenzen
|
||||
|
||||
Erlaubte Elemente:
|
||||
|
||||
`div`, `span`, `p`, `a`, `button`, `img`, `h1`, `h2`, `h3`, `strong`, `em`, `nav`, `section`
|
||||
|
||||
Attribute und Styles werden pro Element über feste Allowlisten geprüft. Insbesondere gelten folgende Regeln:
|
||||
|
||||
- `script`, `iframe`, alle `on*`-Attribute, `javascript:`, `data:` und CSS mit `url()` oder `expression()` werden verworfen.
|
||||
- Bilder benötigen immer ein `alt`; dekorative Bilder verwenden `alt: ""`.
|
||||
- Leere Links und Buttons sind nicht erlaubt; Buttons erhalten immer `type="button"`.
|
||||
- Links mit `target="_blank"` erhalten automatisch `rel="noopener noreferrer"`.
|
||||
- Relative CSS-Pfade und CSS derselben Origin sind erlaubt. Fremde Quellen benötigen HTTPS und einen Eintrag in `allowedCssHosts`.
|
||||
- Maximal 20 Stylesheets, 200 Elemente, acht Ebenen und 100.000 Zeichen Konfiguration werden verarbeitet.
|
||||
|
||||
Unsichere Teilwerte werden kontrolliert verworfen, ohne die SharePoint-Seite zu blockieren. Details erscheinen nur bei `debug: true` in der Browserkonsole mit dem Präfix `[CustomBranding]`.
|
||||
|
||||
## Classic SharePoint
|
||||
|
||||
Der SPFx Application Customizer selbst läuft nicht auf klassischen Seiten. Version 3.0 enthält deshalb eine separate ES5-Runtime mit demselben Sicherheits- und Konfigurationsmodell.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run package
|
||||
npm run build:classic
|
||||
```
|
||||
|
||||
Das Paket wird unter `sharepoint/solution/custom-branding.sppkg` erzeugt.
|
||||
Danach `classic/dist/custom-branding-classic.js` nach beispielsweise `/SiteAssets/custom-branding/` hochladen und zentral registrieren:
|
||||
|
||||
## Installation
|
||||
```powershell
|
||||
.\deployment\add-custombranding.ps1 `
|
||||
-SiteUrl 'http://clshp001/sites/portal' `
|
||||
-ClassicScriptUrl '~sitecollection/SiteAssets/custom-branding/custom-branding-classic.js'
|
||||
```
|
||||
|
||||
1. `custom-branding.sppkg` in den App Catalog laden beziehungsweise ersetzen.
|
||||
2. Die App im Root Web der Site Collection installieren oder aktualisieren.
|
||||
3. Beim Aktivieren wird der Custom Branding Application Customizer automatisch web-scoped registriert.
|
||||
4. PortalSettings mit `Strg+F5` neu laden und den Tab **Custom Branding** konfigurieren.
|
||||
Der site-scoped ScriptLink gilt auch für später angelegte Subwebs. Entfernen lässt er sich mit `-RemoveClassicScriptLink`. Weitere Hinweise stehen in `classic/classic-deployment.md`.
|
||||
|
||||
Das Skript `deployment/add-custombranding.ps1` bleibt fuer automatisierte Rollouts oder eine ausdruecklich site-scoped Registrierung verfuegbar.
|
||||
## PortalSettings v3
|
||||
|
||||
CustomBranding funktioniert unabhängig von PortalSettings. Das geplante PortalSettings-v3-Webpart kann die Action anhand folgender Werte erkennen und die Properties schemaerhaltend bearbeiten:
|
||||
|
||||
- Component ID: `035ba968-6488-4d42-86b3-0470ffcc95b9`
|
||||
- Location: `ClientSideExtension.ApplicationCustomizer`
|
||||
- Scope: `SPSite.UserCustomActions`
|
||||
- Schema: `schemaVersion: 2`
|
||||
|
||||
Ein Editor muss unbekannte Properties erhalten und vor dem Speichern dieselben Element-, Attribut-, URL- und CSS-Grenzen beachten.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Zentrale Registrierung prüfen:
|
||||
|
||||
```powershell
|
||||
$site = Get-SPSite 'http://clshp001/sites/portal'
|
||||
$site.UserCustomActions | Where-Object {
|
||||
$_.ClientSideComponentId -eq [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9'
|
||||
} | Select-Object Id, Title, Location, ClientSideComponentProperties
|
||||
$site.Dispose()
|
||||
```
|
||||
|
||||
Browserkonsole auf einer modernen Seite:
|
||||
|
||||
```javascript
|
||||
performance.getEntriesByType('resource')
|
||||
.map(function (entry) { return entry.name; })
|
||||
.filter(function (url) { return url.toLowerCase().indexOf('custom-branding') >= 0; });
|
||||
|
||||
({
|
||||
header: !!document.getElementById('CustomHeader'),
|
||||
branding: !!document.getElementById('CustomBrandingTopHost'),
|
||||
megaMenu: !!document.getElementById('MegaMenuHost')
|
||||
});
|
||||
```
|
||||
|
||||
Classic-Diagnose:
|
||||
|
||||
```javascript
|
||||
typeof window.CustomBrandingClassic
|
||||
window.CustomBrandingClassic.reload()
|
||||
```
|
||||
|
||||
## Upgrade von 1.x
|
||||
|
||||
1. Paket im App Catalog durch Version `3.0.0.0` ersetzen und bereitstellen.
|
||||
2. `add-custombranding.ps1` einmal pro Site Collection ausführen; bestehende Properties bleiben erhalten.
|
||||
3. Moderne und gegebenenfalls klassische Seiten testen.
|
||||
4. Erst nach erfolgreicher Abnahme alte web-scoped Aktionen als bereinigt bestätigen.
|
||||
|
||||
Ein Downgrade sollte nur zusammen mit einer Sicherung der `ClientSideComponentProperties` erfolgen.
|
||||
|
||||
120
ToDo.md
Normal file
120
ToDo.md
Normal 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
25
build-classic.ps1
Normal 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
|
||||
|
||||
30
classic/classic-deployment.md
Normal file
30
classic/classic-deployment.md
Normal 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()
|
||||
```
|
||||
263
classic/custom-branding-classic.js
Normal file
263
classic/custom-branding-classic.js
Normal 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));
|
||||
@@ -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 -->"
|
||||
}
|
||||
@@ -3,22 +3,9 @@
|
||||
"solution": {
|
||||
"name": "custom-branding-client-side-solution",
|
||||
"id": "03a7c4de-e031-4b83-a683-5ca5c364166e",
|
||||
"version": "1.0.4.0",
|
||||
"version": "3.0.0.0",
|
||||
"includeClientSideAssets": true,
|
||||
"skipFeatureDeployment": false,
|
||||
"features": [
|
||||
{
|
||||
"title": "Custom Branding extension registration",
|
||||
"description": "Registers the Custom Branding Application Customizer in the host web.",
|
||||
"id": "2eb6deaa-a4a0-40c5-9f15-6bdaff36094f",
|
||||
"version": "1.0.4.0",
|
||||
"assets": {
|
||||
"elementManifests": [
|
||||
"elements.xml"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
"skipFeatureDeployment": true
|
||||
},
|
||||
"paths": {
|
||||
"zippedPackage": "solution/custom-branding.sppkg"
|
||||
|
||||
@@ -9,158 +9,40 @@
|
||||
"035ba968-6488-4d42-86b3-0470ffcc95b9": {
|
||||
"location": "ClientSideExtension.ApplicationCustomizer",
|
||||
"properties": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "div",
|
||||
"styles": {
|
||||
"background-color": "#0078d4",
|
||||
"color": "white",
|
||||
"padding": "15px 20px",
|
||||
"text-align": "center",
|
||||
"font-family": "Segoe UI, sans-serif",
|
||||
"font-size": "14px"
|
||||
},
|
||||
"content": "Willkommen auf unserem SharePoint Portal!"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"warning": {
|
||||
"pageUrl": "http://clshp001/",
|
||||
"customActions": {
|
||||
"035ba968-6488-4d42-86b3-0470ffcc95b9": {
|
||||
"location": "ClientSideExtension.ApplicationCustomizer",
|
||||
"properties": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "div",
|
||||
"styles": {
|
||||
"background-color": "#d83b01",
|
||||
"color": "white",
|
||||
"padding": "15px",
|
||||
"text-align": "center",
|
||||
"font-weight": "bold"
|
||||
},
|
||||
"content": "⚠️ Achtung: Wartungsarbeiten am Wochenende"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"withLink": {
|
||||
"pageUrl": "http://clshp001/",
|
||||
"customActions": {
|
||||
"035ba968-6488-4d42-86b3-0470ffcc95b9": {
|
||||
"location": "ClientSideExtension.ApplicationCustomizer",
|
||||
"properties": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "div",
|
||||
"styles": {
|
||||
"background-color": "#0078d4",
|
||||
"color": "white",
|
||||
"padding": "12px 20px",
|
||||
"text-align": "center"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "span",
|
||||
"content": "Wichtige Mitteilung: Systemwartung geplant. ",
|
||||
"styles": {
|
||||
"font-weight": "bold"
|
||||
}
|
||||
"schemaVersion": 2,
|
||||
"enabled": true,
|
||||
"debug": true,
|
||||
"allowedCssHosts": [],
|
||||
"cssfiles": [],
|
||||
"placeholdertop": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "section",
|
||||
"attributes": {
|
||||
"aria-label": "Portalhinweis"
|
||||
},
|
||||
{
|
||||
"type": "a",
|
||||
"content": "Mehr Informationen",
|
||||
"attributes": {
|
||||
"href": "/sites/it/SitePages/Wartung.aspx",
|
||||
"target": "_blank"
|
||||
"styles": {
|
||||
"background-color": "#0078d4",
|
||||
"color": "#ffffff",
|
||||
"padding": "12px 20px",
|
||||
"text-align": "center"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "strong",
|
||||
"content": "Willkommen im SharePoint-Portal"
|
||||
},
|
||||
"styles": {
|
||||
"color": "white",
|
||||
"text-decoration": "underline",
|
||||
"margin-left": "5px"
|
||||
{
|
||||
"type": "span",
|
||||
"content": " – aktuelle Informationen finden Sie im Intranet."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gradient": {
|
||||
"pageUrl": "http://clshp001/",
|
||||
"customActions": {
|
||||
"035ba968-6488-4d42-86b3-0470ffcc95b9": {
|
||||
"location": "ClientSideExtension.ApplicationCustomizer",
|
||||
"properties": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "div",
|
||||
"styles": {
|
||||
"background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
"color": "white",
|
||||
"padding": "20px"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "div",
|
||||
"styles": {
|
||||
"display": "flex",
|
||||
"justify-content": "space-between",
|
||||
"align-items": "center",
|
||||
"max-width": "1200px",
|
||||
"margin": "0 auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "div",
|
||||
"children": [
|
||||
{
|
||||
"type": "p",
|
||||
"styles": {
|
||||
"margin": "0",
|
||||
"font-size": "18px",
|
||||
"font-weight": "bold"
|
||||
},
|
||||
"content": "Neue Funktionen verfügbar!"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"styles": {
|
||||
"margin": "5px 0 0 0",
|
||||
"font-size": "14px"
|
||||
},
|
||||
"content": "Entdecken Sie die neuesten Updates"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"content": "Mehr erfahren",
|
||||
"attributes": {
|
||||
"onclick": "window.location.href='/sites/news/SitePages/Updates.aspx'"
|
||||
},
|
||||
"styles": {
|
||||
"background-color": "white",
|
||||
"color": "#667eea",
|
||||
"border": "none",
|
||||
"padding": "10px 20px",
|
||||
"border-radius": "5px",
|
||||
"cursor": "pointer",
|
||||
"font-weight": "bold"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"placeholderbottom": {
|
||||
"elements": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,100 +5,190 @@ param(
|
||||
|
||||
[string]$CssPath = '',
|
||||
|
||||
[string[]]$AllowedCssHost = @(),
|
||||
|
||||
[switch]$EnableDebug,
|
||||
|
||||
[switch]$Disable,
|
||||
|
||||
[switch]$ResetConfiguration,
|
||||
|
||||
[string]$ClassicScriptUrl = '',
|
||||
|
||||
[switch]$RemoveClassicScriptLink,
|
||||
|
||||
[string]$Description = 'MSFT-Custom-Solution:CustomBranding'
|
||||
)
|
||||
|
||||
Add-PSSnapin Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue | Out-Null
|
||||
if (-not (Get-Command -Name Get-SPSite -ErrorAction SilentlyContinue)) {
|
||||
if (Get-Module -ListAvailable -Name Microsoft.SharePoint.PowerShell) {
|
||||
Import-Module Microsoft.SharePoint.PowerShell -DisableNameChecking
|
||||
}
|
||||
elseif (Get-PSSnapin -Registered -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) {
|
||||
Add-PSSnapin Microsoft.SharePoint.PowerShell
|
||||
}
|
||||
else {
|
||||
throw 'Die SharePoint PowerShell-Komponenten wurden nicht gefunden. Bitte in der SharePoint Management Shell ausfuehren.'
|
||||
}
|
||||
}
|
||||
|
||||
$componentId = '035ba968-6488-4d42-86b3-0470ffcc95b9'
|
||||
$location = 'ClientSideExtension.ApplicationCustomizer'
|
||||
$name = 'CustomBranding'
|
||||
$title = 'Custom Branding'
|
||||
$componentId = [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9'
|
||||
$componentIdText = $componentId.ToString().ToLowerInvariant()
|
||||
$componentLocation = 'ClientSideExtension.ApplicationCustomizer'
|
||||
$componentName = 'CustomBranding'
|
||||
$componentTitle = 'Custom Branding Application Customizer'
|
||||
$classicActionName = 'CustomBranding.Classic.ScriptLink'
|
||||
|
||||
function Get-CustomBrandingPropertiesJson {
|
||||
param(
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$config = @{
|
||||
function New-DefaultCustomBrandingConfiguration {
|
||||
return @{
|
||||
schemaVersion = 2
|
||||
enabled = $true
|
||||
debug = $false
|
||||
allowedCssHosts = @()
|
||||
cssfiles = @()
|
||||
placeholdertop = @{ elements = @() }
|
||||
placeholderbottom = @{ elements = @() }
|
||||
}
|
||||
}
|
||||
|
||||
if ($Path -and $Path.Trim().Length -gt 0) {
|
||||
$config.cssfiles += @{ path = $Path.Trim() }
|
||||
function ConvertTo-Hashtable {
|
||||
param([object]$InputObject)
|
||||
if ($null -eq $InputObject) { return $null }
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
$result = @{}
|
||||
foreach ($key in $InputObject.Keys) { $result[$key] = ConvertTo-Hashtable $InputObject[$key] }
|
||||
return $result
|
||||
}
|
||||
if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
|
||||
return @($InputObject | ForEach-Object { ConvertTo-Hashtable $_ })
|
||||
}
|
||||
if ($InputObject.PSObject -and $InputObject.PSObject.Properties.Count -gt 0 -and
|
||||
-not ($InputObject -is [string]) -and -not ($InputObject.GetType().IsPrimitive)) {
|
||||
$result = @{}
|
||||
foreach ($property in $InputObject.PSObject.Properties) { $result[$property.Name] = ConvertTo-Hashtable $property.Value }
|
||||
return $result
|
||||
}
|
||||
return $InputObject
|
||||
}
|
||||
|
||||
return ($config | ConvertTo-Json -Depth 10 -Compress)
|
||||
function Get-ExistingConfiguration {
|
||||
param([Microsoft.SharePoint.SPUserCustomAction]$Action)
|
||||
if ($ResetConfiguration -or -not $Action -or [string]::IsNullOrWhiteSpace($Action.ClientSideComponentProperties)) {
|
||||
return New-DefaultCustomBrandingConfiguration
|
||||
}
|
||||
try {
|
||||
$parsed = $Action.ClientSideComponentProperties | ConvertFrom-Json
|
||||
$configuration = ConvertTo-Hashtable $parsed
|
||||
if (-not $configuration) { return New-DefaultCustomBrandingConfiguration }
|
||||
return $configuration
|
||||
}
|
||||
catch {
|
||||
Write-Warning 'Vorhandene ClientSideComponentProperties waren ungueltig und werden durch Standardwerte ersetzt.'
|
||||
return New-DefaultCustomBrandingConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-WebScopedComponentActions {
|
||||
param(
|
||||
[Microsoft.SharePoint.SPSite]$CurrentSite,
|
||||
[string]$CurrentComponentId,
|
||||
[string]$CurrentLocation
|
||||
)
|
||||
|
||||
$removedCount = 0
|
||||
|
||||
param([Microsoft.SharePoint.SPSite]$CurrentSite)
|
||||
$removed = 0
|
||||
foreach ($web in $CurrentSite.AllWebs) {
|
||||
try {
|
||||
$webActions = @($web.UserCustomActions | Where-Object {
|
||||
$_.Location -eq $CurrentLocation -and
|
||||
$actions = @($web.UserCustomActions | Where-Object {
|
||||
$_.Location -eq $componentLocation -and
|
||||
$_.ClientSideComponentId -and
|
||||
$_.ClientSideComponentId.ToString().ToLower() -eq $CurrentComponentId
|
||||
$_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText
|
||||
})
|
||||
|
||||
foreach ($webAction in $webActions) {
|
||||
$web.UserCustomActions.Delete($webAction.Id)
|
||||
$removedCount++
|
||||
}
|
||||
|
||||
if ($webActions.Count -gt 0) {
|
||||
$web.Update()
|
||||
foreach ($action in $actions) {
|
||||
$action.Delete()
|
||||
$removed++
|
||||
}
|
||||
if ($actions.Count -gt 0) { $web.Update() }
|
||||
}
|
||||
finally {
|
||||
$web.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
return $removedCount
|
||||
return $removed
|
||||
}
|
||||
|
||||
$site = Get-SPSite -Identity $SiteUrl
|
||||
try {
|
||||
$removedWebScopedActions = Remove-WebScopedComponentActions -CurrentSite $site -CurrentComponentId $componentId -CurrentLocation $location
|
||||
$siteActions = @($site.UserCustomActions | Where-Object {
|
||||
$_.Location -eq $componentLocation -and
|
||||
$_.ClientSideComponentId -and
|
||||
$_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText
|
||||
})
|
||||
$action = $siteActions | Select-Object -First 1
|
||||
$configuration = Get-ExistingConfiguration -Action $action
|
||||
|
||||
$existingAction = $site.UserCustomActions | Where-Object {
|
||||
$_.Location -eq $location -and $_.ClientSideComponentId -and $_.ClientSideComponentId.ToString().ToLower() -eq $componentId
|
||||
} | Select-Object -First 1
|
||||
$configuration.schemaVersion = 2
|
||||
if (-not $configuration.ContainsKey('placeholdertop')) { $configuration.placeholdertop = @{ elements = @() } }
|
||||
if (-not $configuration.ContainsKey('placeholderbottom')) { $configuration.placeholderbottom = @{ elements = @() } }
|
||||
if (-not $configuration.ContainsKey('cssfiles')) { $configuration.cssfiles = @() }
|
||||
if (-not $configuration.ContainsKey('allowedCssHosts')) { $configuration.allowedCssHosts = @() }
|
||||
if ($PSBoundParameters.ContainsKey('EnableDebug')) { $configuration.debug = [bool]$EnableDebug.IsPresent }
|
||||
if ($PSBoundParameters.ContainsKey('Disable')) { $configuration.enabled = -not [bool]$Disable.IsPresent }
|
||||
|
||||
if ($existingAction) {
|
||||
$action = $existingAction
|
||||
Write-Host 'Aktualisiere vorhandene site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow
|
||||
if (-not [string]::IsNullOrWhiteSpace($CssPath)) {
|
||||
$trimmedPath = $CssPath.Trim()
|
||||
$existingPaths = @($configuration.cssfiles | ForEach-Object {
|
||||
if ($_ -is [System.Collections.IDictionary]) { [string]$_['path'] } else { [string]$_.path }
|
||||
})
|
||||
if ($existingPaths -notcontains $trimmedPath) {
|
||||
$configuration.cssfiles = @($configuration.cssfiles) + @(@{ path = $trimmedPath; media = 'all' })
|
||||
}
|
||||
}
|
||||
if ($AllowedCssHost.Count -gt 0) {
|
||||
$configuration.allowedCssHosts = @($AllowedCssHost | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique)
|
||||
}
|
||||
|
||||
if (-not $action) {
|
||||
$action = $site.UserCustomActions.Add()
|
||||
Write-Host 'Erstelle zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
$action = $site.UserCustomActions.Add()
|
||||
Write-Host 'Erstelle neue site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow
|
||||
Write-Host 'Aktualisiere zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$action.Name = $name
|
||||
$action.Title = $title
|
||||
$action.Name = $componentName
|
||||
$action.Title = $componentTitle
|
||||
$action.Description = $Description
|
||||
$action.Location = $location
|
||||
$action.ClientSideComponentId = [Guid]$componentId
|
||||
$action.ClientSideComponentProperties = Get-CustomBrandingPropertiesJson -Path $CssPath
|
||||
$action.Location = $componentLocation
|
||||
$action.Sequence = 100
|
||||
$action.ClientSideComponentId = $componentId
|
||||
$action.ClientSideComponentProperties = [string]($configuration | ConvertTo-Json -Depth 20 -Compress)
|
||||
$action.Update()
|
||||
|
||||
Write-Host 'Custom Branding wurde site-scoped registriert.' -ForegroundColor Green
|
||||
Write-Host ('Entfernte web-scoped Eintraege: ' + $removedWebScopedActions)
|
||||
Write-Host ('Beschreibung: ' + $Description)
|
||||
@($siteActions | Select-Object -Skip 1) | ForEach-Object {
|
||||
Write-Host ('Entferne doppelte Site-Collection-Action: ' + $_.Id) -ForegroundColor Yellow
|
||||
$_.Delete()
|
||||
}
|
||||
$removedWebActions = Remove-WebScopedComponentActions -CurrentSite $site
|
||||
|
||||
$classicActions = @($site.UserCustomActions | Where-Object {
|
||||
$_.Location -eq 'ScriptLink' -and $_.Name -eq $classicActionName
|
||||
})
|
||||
if ($RemoveClassicScriptLink) {
|
||||
foreach ($classicAction in $classicActions) { $classicAction.Delete() }
|
||||
Write-Host 'Classic ScriptLink wurde entfernt.' -ForegroundColor Yellow
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($ClassicScriptUrl)) {
|
||||
$classicAction = $classicActions | Select-Object -First 1
|
||||
if (-not $classicAction) { $classicAction = $site.UserCustomActions.Add() }
|
||||
$classicAction.Name = $classicActionName
|
||||
$classicAction.Title = 'CustomBranding Classic Runtime'
|
||||
$classicAction.Description = $Description
|
||||
$classicAction.Location = 'ScriptLink'
|
||||
$classicAction.Sequence = 101
|
||||
$classicAction.ScriptSrc = $ClassicScriptUrl.Trim()
|
||||
$classicAction.Update()
|
||||
@($classicActions | Select-Object -Skip 1) | ForEach-Object { $_.Delete() }
|
||||
Write-Host ('Classic ScriptLink: ' + $classicAction.ScriptSrc) -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host 'CustomBranding wurde zentral fuer die Site Collection registriert.' -ForegroundColor Green
|
||||
Write-Host ('Entfernte web-scoped Actions: ' + $removedWebActions)
|
||||
Write-Host ('Properties: ' + $action.ClientSideComponentProperties)
|
||||
}
|
||||
finally {
|
||||
if ($site) {
|
||||
$site.Dispose()
|
||||
}
|
||||
if ($site) { $site.Dispose() }
|
||||
}
|
||||
44
examples/custom-branding.css
Normal file
44
examples/custom-branding.css
Normal 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;
|
||||
}
|
||||
}
|
||||
41
examples/custom-branding.example.json
Normal file
41
examples/custom-branding.example.json
Normal 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
2
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "custom-branding",
|
||||
"version": "1.0.4",
|
||||
"version": "3.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
15
package.json
15
package.json
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "custom-branding",
|
||||
"version": "1.0.4",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"main": "lib/index.js",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"node": ">=6.9.0 <9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "gulp bundle",
|
||||
"build": "npm run build:classic && gulp bundle",
|
||||
"build:classic": "powershell -NoProfile -ExecutionPolicy Bypass -File ./build-classic.ps1",
|
||||
"clean": "gulp clean",
|
||||
"test": "gulp test",
|
||||
"package": "gulp clean && gulp bundle --ship && gulp package-solution --ship"
|
||||
"test": "tsc -p tests/tsconfig.json && node tests/BrandingConfig.test.js && node tests/BrandingDomRenderer.test.js && node tests/BrandingCssLoader.test.js && node tests/validate-static-assets.js && powershell -NoProfile -ExecutionPolicy Bypass -File ./tests/validate-project.ps1",
|
||||
"package": "npm test && npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/sp-core-library": "~1.4.0",
|
||||
"@microsoft/decorators": "~1.4.0",
|
||||
"@types/webpack-env": "1.13.1",
|
||||
"@types/es6-promise": "0.0.33",
|
||||
"@microsoft/sp-dialog": "~1.4.0",
|
||||
"@microsoft/sp-application-base": "~1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,6 +27,7 @@
|
||||
"gulp": "~3.9.1",
|
||||
"@types/chai": "3.4.34",
|
||||
"@types/mocha": "2.2.38",
|
||||
"ajv": "~5.2.2"
|
||||
"ajv": "~5.2.2",
|
||||
"jsdom": "9.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="{"cssfiles":[],"placeholdertop":{"elements":[]},"placeholderbottom":{"elements":[]}}" />
|
||||
</Elements>
|
||||
306
src/extensions/customBranding/BrandingConfig.ts
Normal file
306
src/extensions/customBranding/BrandingConfig.ts
Normal 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;
|
||||
}
|
||||
|
||||
97
src/extensions/customBranding/BrandingCssLoader.ts
Normal file
97
src/extensions/customBranding/BrandingCssLoader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/extensions/customBranding/BrandingDomRenderer.ts
Normal file
44
src/extensions/customBranding/BrandingDomRenderer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
45
src/extensions/customBranding/BrandingTypes.ts
Normal file
45
src/extensions/customBranding/BrandingTypes.ts
Normal 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[];
|
||||
}
|
||||
|
||||
@@ -1,373 +1,175 @@
|
||||
import { override } from '@microsoft/decorators';
|
||||
// Legacy SPFx 1.4 implementation; incremental modernization is tracked separately.
|
||||
// tslint:disable:max-line-length no-consecutive-blank-lines no-function-expression no-trailing-whitespace typedef
|
||||
|
||||
// tslint:disable:no-any max-line-length
|
||||
import { override } from '@microsoft/decorators';
|
||||
import { Log } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
BaseApplicationCustomizer,
|
||||
PlaceholderContent,
|
||||
PlaceholderName
|
||||
} from '@microsoft/sp-application-base';
|
||||
import * as strings from 'CustomBrandingApplicationCustomizerStrings';
|
||||
import { BrandingCssLoader } from './BrandingCssLoader';
|
||||
import { normalizeBrandingConfig } from './BrandingConfig';
|
||||
import { BrandingDomRenderer } from './BrandingDomRenderer';
|
||||
import {
|
||||
IBrandingNormalizationResult,
|
||||
IBrandingElement,
|
||||
ICustomBrandingApplicationCustomizerProperties,
|
||||
ICustomBrandingConfig
|
||||
} from './BrandingTypes';
|
||||
|
||||
const LOG_SOURCE: string = 'CustomBrandingApplicationCustomizer';
|
||||
const COMPONENT_ID: string = '035ba968-6488-4d42-86b3-0470ffcc95b9';
|
||||
|
||||
/**
|
||||
* CSS-Datei Definition
|
||||
*/
|
||||
export interface ICssFile {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition eines HTML-Elements
|
||||
*/
|
||||
export interface IBrandingElement {
|
||||
type: 'div' | 'span' | 'p' | 'a' | 'button' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em';
|
||||
content?: string;
|
||||
attributes?: { [key: string]: string };
|
||||
styles?: { [key: string]: string };
|
||||
children?: IBrandingElement[];
|
||||
}
|
||||
|
||||
export interface IPlaceholderConfig {
|
||||
elements?: IBrandingElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauptkonfiguration für das Branding
|
||||
*/
|
||||
export interface IBrandingConfig {
|
||||
cssfiles?: ICssFile[];
|
||||
placeholdertop?: IBrandingElement[];
|
||||
placeholderbottom?: IBrandingElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties für den CustomBranding Application Customizer
|
||||
*/
|
||||
export interface ICustomBrandingApplicationCustomizerProperties {
|
||||
/**
|
||||
* Array von CSS-Dateien die geladen werden sollen
|
||||
*/
|
||||
cssfiles?: ICssFile[];
|
||||
|
||||
/**
|
||||
* Array von HTML-Elementen für den Placeholder Top
|
||||
*/
|
||||
placeholdertop?: IPlaceholderConfig;
|
||||
|
||||
/**
|
||||
* Array von HTML-Elementen für den Placeholder Bottom
|
||||
*/
|
||||
placeholderbottom?: IPlaceholderConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomBranding Application Customizer
|
||||
* Kompiliert JSON-Konfiguration zu HTML und fügt es in den Top Placeholder ein
|
||||
* Lädt optional CSS-Dateien
|
||||
*/
|
||||
export default class CustomBrandingApplicationCustomizer
|
||||
extends BaseApplicationCustomizer<ICustomBrandingApplicationCustomizerProperties> {
|
||||
|
||||
private _topPlaceholder: PlaceholderContent | undefined;
|
||||
private _bottomPlaceholder: PlaceholderContent | undefined;
|
||||
private _loadedCssFiles: string[] = [];
|
||||
private _topHost: HTMLElement | undefined;
|
||||
private _bottomHost: HTMLElement | undefined;
|
||||
private _renderer: BrandingDomRenderer = new BrandingDomRenderer();
|
||||
private _cssLoader: BrandingCssLoader | undefined;
|
||||
private _config: ICustomBrandingConfig | undefined;
|
||||
private _isDisposed: boolean = false;
|
||||
|
||||
@override
|
||||
public onInit(): Promise<void> {
|
||||
Log.info(LOG_SOURCE, 'Initialized CustomBrandingApplicationCustomizer');
|
||||
const result: IBrandingNormalizationResult = normalizeBrandingConfig(
|
||||
this.properties,
|
||||
this.context.pageContext.site.absoluteUrl
|
||||
);
|
||||
this._config = result.config;
|
||||
this.debug(strings.Initialized + ' 3.0.0.', {
|
||||
schemaVersion: this._config.schemaVersion,
|
||||
cssFileCount: this._config.cssfiles.length,
|
||||
warningCount: result.warnings.length
|
||||
});
|
||||
for (let i: number = 0; i < result.warnings.length; i++) {
|
||||
this.debug('Configuration warning: ' + result.warnings[i]);
|
||||
}
|
||||
|
||||
// CSS-Dateien laden
|
||||
this._loadCssFiles();
|
||||
|
||||
// Auf Placeholder-Änderungen reagieren
|
||||
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceHolders);
|
||||
|
||||
// Initial rendern
|
||||
this._renderPlaceHolders();
|
||||
if (!this._config.enabled) {
|
||||
this.debug('CustomBranding is disabled by configuration.');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this._cssLoader = new BrandingCssLoader(COMPONENT_ID, this.debug.bind(this));
|
||||
this._cssLoader.load(this._config.cssfiles);
|
||||
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders);
|
||||
this._renderPlaceholders();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt CSS-Dateien aus der Konfiguration
|
||||
*/
|
||||
private _loadCssFiles(): void {
|
||||
if (this.properties && this.properties.cssfiles && Array.isArray(this.properties.cssfiles)) {
|
||||
console.log('CustomBranding: Loading CSS files...');
|
||||
private _renderPlaceholders(): void {
|
||||
if (this._isDisposed || !this._config || !this._config.enabled) { return; }
|
||||
|
||||
for (let i = 0; i < this.properties.cssfiles.length; i++) {
|
||||
const cssFile = this.properties.cssfiles[i];
|
||||
|
||||
if (cssFile && cssFile.path) {
|
||||
this._injectCssFile(cssFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt eine CSS-Datei in den Head ein
|
||||
*/
|
||||
private _injectCssFile(cssPath: string): void {
|
||||
// Prüfen ob die Datei bereits geladen wurde
|
||||
for (let i = 0; i < this._loadedCssFiles.length; i++) {
|
||||
if (this._loadedCssFiles[i] === cssPath) {
|
||||
console.log('CustomBranding: CSS file already loaded: ' + cssPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Link-Element erstellen
|
||||
const linkElement: HTMLLinkElement = document.createElement('link');
|
||||
linkElement.rel = 'stylesheet';
|
||||
linkElement.type = 'text/css';
|
||||
linkElement.href = cssPath;
|
||||
linkElement.setAttribute('data-custom-branding', 'true');
|
||||
|
||||
// Event-Handler für erfolgreiches Laden
|
||||
linkElement.onload = function () {
|
||||
console.log('CustomBranding: CSS loaded successfully: ' + cssPath);
|
||||
};
|
||||
|
||||
// Event-Handler für Fehler
|
||||
linkElement.onerror = function () {
|
||||
console.error('CustomBranding: Failed to load CSS: ' + cssPath);
|
||||
};
|
||||
|
||||
// In Head einfügen
|
||||
document.head.appendChild(linkElement);
|
||||
|
||||
// Zur Liste hinzufügen
|
||||
this._loadedCssFiles.push(cssPath);
|
||||
|
||||
console.log('CustomBranding: CSS file injected: ' + cssPath);
|
||||
} catch (error) {
|
||||
console.error('CustomBranding: Error injecting CSS file: ' + cssPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderPlaceHolders(): void {
|
||||
console.log('CustomBrandingApplicationCustomizer._renderPlaceHolders()');
|
||||
|
||||
// Prüfen ob Top Placeholder verfügbar ist
|
||||
if (!this._topPlaceholder && !this._bottomPlaceholder) {
|
||||
if (!this._topPlaceholder) {
|
||||
this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(
|
||||
PlaceholderName.Top,
|
||||
{ onDispose: this._onDispose }
|
||||
{ onDispose: this._onTopPlaceholderDisposed }
|
||||
);
|
||||
|
||||
}
|
||||
if (!this._bottomPlaceholder) {
|
||||
this._bottomPlaceholder = this.context.placeholderProvider.tryCreateContent(
|
||||
PlaceholderName.Bottom,
|
||||
{ onDispose: this._onDispose }
|
||||
{ onDispose: this._onBottomPlaceholderDisposed }
|
||||
);
|
||||
}
|
||||
|
||||
// Falls Placeholder nicht verfügbar, abbrechen
|
||||
if (!this._topPlaceholder) {
|
||||
console.error('CustomBranding: Top placeholder not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Falls Placeholder nicht verfügbar, abbrechen
|
||||
if (!this._bottomPlaceholder) {
|
||||
console.error('CustomBranding: Bottom placeholder not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._topPlaceholder.domElement && this._bottomPlaceholder.domElement) {
|
||||
// Container erstellen mit hoher Priorität
|
||||
this.renderPlaceHolder(this._topPlaceholder.domElement, this._bottomPlaceholder.domElement);
|
||||
|
||||
console.log('CustomBranding: HTML injected successfully');
|
||||
}
|
||||
if (this._topPlaceholder && this._topPlaceholder.domElement) {
|
||||
this._topPlaceholder.domElement.id = 'CustomHeader';
|
||||
this._topHost = this.getOrCreateOwnedHost(
|
||||
this._topPlaceholder.domElement,
|
||||
'CustomBrandingTopHost'
|
||||
);
|
||||
this.renderSafely(this._topHost, this._config.placeholdertop.elements);
|
||||
}
|
||||
if (this._bottomPlaceholder && this._bottomPlaceholder.domElement) {
|
||||
this._bottomPlaceholder.domElement.id = 'CustomFooter';
|
||||
this._bottomHost = this.getOrCreateOwnedHost(
|
||||
this._bottomPlaceholder.domElement,
|
||||
'CustomBrandingBottomHost'
|
||||
);
|
||||
this.renderSafely(this._bottomHost, this._config.placeholderbottom.elements);
|
||||
}
|
||||
}
|
||||
|
||||
private renderPlaceHolder(topcontainer: HTMLElement, bottomcontainer: HTMLElement) {
|
||||
|
||||
if (!this.properties) {
|
||||
console.log('CustomBranding: No properties provided');
|
||||
return;
|
||||
private getOrCreateOwnedHost(parent: HTMLElement, id: string): HTMLElement {
|
||||
let host: HTMLElement = parent.querySelector('#' + id) as HTMLElement;
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = id;
|
||||
host.setAttribute('data-custom-branding-owner', COMPONENT_ID);
|
||||
parent.insertBefore(host, parent.firstChild);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
private renderSafely(host: HTMLElement, elements: IBrandingElement[]): void {
|
||||
try {
|
||||
// Top-Konfiguration unverändert übernehmen
|
||||
const topConfig: IPlaceholderConfig | undefined =
|
||||
this.properties.placeholdertop;
|
||||
|
||||
// Bottom-Konfiguration klonen oder initialisieren
|
||||
const bottomConfig: IPlaceholderConfig =
|
||||
this.properties.placeholderbottom
|
||||
? { ...this.properties.placeholderbottom }
|
||||
: { elements: [] };
|
||||
|
||||
// Admin-Link nur für Site Collection Admins ergänzen
|
||||
if (this._isSiteAdmin()) {
|
||||
bottomConfig.elements = bottomConfig.elements || [];
|
||||
bottomConfig.elements.push(this._getAdminFooterElement());
|
||||
}
|
||||
|
||||
const config: IBrandingConfig = {
|
||||
cssfiles: this.properties.cssfiles,
|
||||
placeholdertop: topConfig.elements,
|
||||
placeholderbottom: bottomConfig.elements
|
||||
};
|
||||
|
||||
console.log('CustomBranding: Compiling JSON to HTML...');
|
||||
const compiled = this._compileToHtml(config);
|
||||
|
||||
topcontainer.id = 'CustomHeader';
|
||||
topcontainer.innerHTML = compiled.top;
|
||||
|
||||
bottomcontainer.id = 'CustomFooter';
|
||||
bottomcontainer.innerHTML = compiled.bottom;
|
||||
|
||||
this._renderer.render(host, elements);
|
||||
} catch (error) {
|
||||
console.error('CustomBranding: Error compiling configuration', error);
|
||||
this._renderer.clear(host);
|
||||
const status: HTMLElement = document.createElement('div');
|
||||
status.className = 'custom-branding-status';
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = strings.RenderError;
|
||||
host.appendChild(status);
|
||||
this.debug('Rendering failed.', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Kompiliert die JSON-Konfiguration zu HTML
|
||||
*/
|
||||
private _compileToHtml(config: IBrandingConfig): { top: string; bottom: string } {
|
||||
// Compile Top and Bottom separately
|
||||
let topHtml: string = '';
|
||||
let bottomHtml: string = '';
|
||||
if (config.placeholdertop && Array.isArray(config.placeholdertop)) {
|
||||
for (let i = 0; i < config.placeholdertop.length; i++) { topHtml += this._createElement(config.placeholdertop[i]); }
|
||||
}
|
||||
if (config.placeholderbottom && Array.isArray(config.placeholderbottom)) {
|
||||
for (let i = 0; i < config.placeholderbottom.length; i++) { bottomHtml += this._createElement(config.placeholderbottom[i]); }
|
||||
}
|
||||
return { top: topHtml, bottom: bottomHtml };
|
||||
private _onTopPlaceholderDisposed = (): void => {
|
||||
this.removeHost(this._topHost);
|
||||
this._topHost = undefined;
|
||||
this._topPlaceholder = undefined;
|
||||
this.debug('Top placeholder disposed; waiting for a new placeholder.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt HTML für ein einzelnes Element
|
||||
*/
|
||||
private _createElement(element: IBrandingElement): string {
|
||||
const tag = element.type || 'div';
|
||||
let html = '<' + tag;
|
||||
|
||||
// Attribute hinzufügen
|
||||
if (element.attributes) {
|
||||
for (const key in element.attributes) {
|
||||
if (element.attributes.hasOwnProperty(key)) {
|
||||
const value = element.attributes[key];
|
||||
html += ' ' + key + '="' + this._escapeHtml(value) + '"';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styles hinzufügen
|
||||
if (element.styles) {
|
||||
const styleArray: string[] = [];
|
||||
for (const key in element.styles) {
|
||||
if (element.styles.hasOwnProperty(key)) {
|
||||
const value = element.styles[key];
|
||||
styleArray.push(key + ':' + value);
|
||||
}
|
||||
}
|
||||
if (styleArray.length > 0) {
|
||||
const styleString = styleArray.join(';');
|
||||
html += ' style="' + styleString + '"';
|
||||
}
|
||||
}
|
||||
|
||||
html += '>';
|
||||
|
||||
// Content hinzufügen
|
||||
if (element.content) {
|
||||
html += this._escapeHtml(element.content);
|
||||
}
|
||||
|
||||
// Kinder hinzufügen
|
||||
if (element.children && Array.isArray(element.children)) {
|
||||
for (let i = 0; i < element.children.length; i++) {
|
||||
html += this._createElement(element.children[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Self-closing Tags behandeln
|
||||
const selfClosingTags = ['img', 'br', 'hr', 'input'];
|
||||
let isSelfClosing = false;
|
||||
for (let i = 0; i < selfClosingTags.length; i++) {
|
||||
if (selfClosingTags[i] === tag) {
|
||||
isSelfClosing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSelfClosing) {
|
||||
html += '</' + tag + '>';
|
||||
}
|
||||
|
||||
return html;
|
||||
private _onBottomPlaceholderDisposed = (): void => {
|
||||
this.removeHost(this._bottomHost);
|
||||
this._bottomHost = undefined;
|
||||
this._bottomPlaceholder = undefined;
|
||||
this.debug('Bottom placeholder disposed; waiting for a new placeholder.');
|
||||
}
|
||||
|
||||
private _isSiteAdmin(): boolean {
|
||||
return this.context.pageContext.legacyPageContext.isSiteAdmin === true;
|
||||
@override
|
||||
protected onDispose(): void {
|
||||
if (this._isDisposed) { return; }
|
||||
this._isDisposed = true;
|
||||
this.context.placeholderProvider.changedEvent.remove(this, this._renderPlaceholders);
|
||||
this.removeHost(this._topHost);
|
||||
this.removeHost(this._bottomHost);
|
||||
if (this._cssLoader) {
|
||||
this._cssLoader.dispose();
|
||||
}
|
||||
this._topHost = undefined;
|
||||
this._bottomHost = undefined;
|
||||
this._topPlaceholder = undefined;
|
||||
this._bottomPlaceholder = undefined;
|
||||
this._cssLoader = undefined;
|
||||
this.debug('CustomBranding disposed.');
|
||||
this._config = undefined;
|
||||
}
|
||||
|
||||
private _getAdminFooterElement(): IBrandingElement {
|
||||
const siteUrl = this.context.pageContext.site.absoluteUrl;
|
||||
|
||||
return {
|
||||
type: 'div',
|
||||
styles: {
|
||||
'text-align': 'right',
|
||||
'padding': '8px 16px',
|
||||
'border-top': '1px solid #e1e1e1',
|
||||
'background-color': '#f8f8f8',
|
||||
'font-size': '13px'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'a',
|
||||
content: 'Einstellungen',
|
||||
attributes: {
|
||||
href: `${siteUrl}/SitePages/PortalSettings.aspx`
|
||||
},
|
||||
styles: {
|
||||
'text-decoration': 'none',
|
||||
'font-weight': '600'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
private removeHost(host: HTMLElement | undefined): void {
|
||||
if (host && host.parentNode) {
|
||||
host.parentNode.removeChild(host);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaped HTML-Zeichen für Sicherheit
|
||||
*/
|
||||
private _escapeHtml(text: string): string {
|
||||
const map: { [key: string]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
return text.replace(/[&<>"']/g, function (m) {
|
||||
return map[m];
|
||||
});
|
||||
}
|
||||
|
||||
private _onDispose(): void {
|
||||
console.log('CustomBrandingApplicationCustomizer._onDispose()');
|
||||
|
||||
// CSS-Dateien beim Dispose entfernen
|
||||
const cssLinks = document.querySelectorAll('link[data-custom-branding="true"]');
|
||||
for (let i = 0; i < cssLinks.length; i++) {
|
||||
const link = cssLinks[i];
|
||||
if (link.parentNode) {
|
||||
link.parentNode.removeChild(link);
|
||||
}
|
||||
private debug(message: string, data?: any): void {
|
||||
if (!this._config || !this._config.debug) { return; }
|
||||
Log.info(LOG_SOURCE, message);
|
||||
if (window.console && window.console.log) {
|
||||
console.log('[CustomBranding] ' + message, data || '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
IBrandingElement,
|
||||
ICssFile,
|
||||
ICustomBrandingApplicationCustomizerProperties,
|
||||
IPlaceholderConfig
|
||||
} from './BrandingTypes';
|
||||
|
||||
7
src/extensions/customBranding/loc/de-de.js
Normal file
7
src/extensions/customBranding/loc/de-de.js
Normal file
@@ -0,0 +1,7 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"Title": "CustomBrandingApplicationCustomizer",
|
||||
"Initialized": "CustomBranding wurde initialisiert",
|
||||
"RenderError": "Das konfigurierte Branding konnte nicht dargestellt werden."
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"Title": "CustomBrandingApplicationCustomizer"
|
||||
"Title": "CustomBrandingApplicationCustomizer",
|
||||
"Initialized": "CustomBranding initialized",
|
||||
"RenderError": "The configured branding could not be rendered."
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
declare interface ICustomBrandingApplicationCustomizerStrings {
|
||||
Title: string;
|
||||
Initialized: string;
|
||||
RenderError: string;
|
||||
}
|
||||
|
||||
declare module 'CustomBrandingApplicationCustomizerStrings' {
|
||||
|
||||
@@ -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';
|
||||
|
||||
85
tests/BrandingConfig.test.js
Normal file
85
tests/BrandingConfig.test.js
Normal 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.');
|
||||
56
tests/BrandingCssLoader.test.js
Normal file
56
tests/BrandingCssLoader.test.js
Normal 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.');
|
||||
59
tests/BrandingDomRenderer.test.js
Normal file
59
tests/BrandingDomRenderer.test.js
Normal 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
17
tests/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
23
tests/validate-project.ps1
Normal file
23
tests/validate-project.ps1
Normal 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."
|
||||
46
tests/validate-static-assets.js
Normal file
46
tests/validate-static-assets.js
Normal 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.');
|
||||
Reference in New Issue
Block a user