Compare commits
10
Commits
b99ac31f4d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e98e88a7b | ||
|
|
281dfa68de | ||
|
|
36bcef54e7 | ||
|
|
583964a792 | ||
|
|
0bf62ecadf | ||
|
|
41de64fd1d | ||
|
|
fae34b7ed8 | ||
|
|
c529716a18 | ||
|
|
6f548362e2 | ||
|
|
490e9adbd8 |
@@ -1,20 +1,117 @@
|
||||
# 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.5 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
|
||||
├── Branding-Elemente mit `data-custom-branding-placement="top"`
|
||||
└── CustomNavigation (wird nicht verändert)
|
||||
|
||||
CustomFooter
|
||||
└── CustomBrandingBottomHost
|
||||
```
|
||||
|
||||
CustomBranding rendert seine Top-Elemente direkt in `CustomHeader` und entfernt beim erneuten Rendern nur die über `data-custom-branding-owner` markierten eigenen Knoten. `CustomNavigation` und andere Erweiterungen im selben Placeholder bleiben erhalten.
|
||||
|
||||
Die zentrale Custom-Branding-Action verwendet `Sequence = 90`. MegaMenu verwendet `100` und Current Navigation `110`, sodass Custom Branding den gemeinsamen Header zuerst vorbereitet. Die DOM-Integration bleibt trotzdem unabhängig von der tatsächlichen asynchronen Fertigstellungsreihenfolge.
|
||||
|
||||
## 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,
|
||||
"portalSettings": {
|
||||
"providerKey": "custombranding",
|
||||
"contractVersion": 1,
|
||||
"minimumPortalSettingsVersion": "3.0.0"
|
||||
},
|
||||
"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 +119,103 @@ 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:
|
||||
Eine eigene Suche kann deklarativ als `form` mit einem `input type="search"` und einem Submit-Button unter
|
||||
`placeholdertop.elements` konfiguriert werden. Formulare werden ausschließlich als GET-Formulare akzeptiert;
|
||||
unsichere oder protokollrelative Action-URLs werden verworfen. Das vollständige Beispiel enthält eine solche Suche.
|
||||
|
||||
Ist `placeholderbottom.elements` leer oder fehlt die Eigenschaft, rendert CustomBranding automatisch den bisherigen
|
||||
**Portal Settings**-Link auf `~sitecollection/SitePages/PortalSettings.aspx`. Sobald eigene Footer-Elemente konfiguriert
|
||||
sind, ersetzen sie diesen Standard.
|
||||
|
||||
### 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. PortalSettings ab Version 3.0.1 kann die zentrale Action anhand folgender Werte erkennen, die Properties schemaerhaltend bearbeiten und eine noch fehlende Action beim ersten Speichern selbst anlegen:
|
||||
|
||||
- Component ID: `035ba968-6488-4d42-86b3-0470ffcc95b9`
|
||||
- Location: `ClientSideExtension.ApplicationCustomizer`
|
||||
- Scope: `SPSite.UserCustomActions`
|
||||
- Schema: `schemaVersion: 2`
|
||||
- Provider-Marker: `portalSettings.providerKey: custombranding`
|
||||
|
||||
Ist noch keine Action vorhanden, erscheint CustomBranding in PortalSettings als **Bereit zur Aktivierung**. Voraussetzung ist lediglich, dass `custom-branding.sppkg` bereits im App Catalog bereitgestellt wurde. Der Button **CustomBranding aktivieren** erzeugt anschließend genau eine site-scoped Action. Das PowerShell-Skript bleibt für automatisierte Rollouts und Classic-ScriptLink-Registrierungen verfügbar.
|
||||
|
||||
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.querySelectorAll('#CustomHeader > [data-custom-branding-placement="top"]').length,
|
||||
megaMenu: !!document.getElementById('CustomNavigation')
|
||||
});
|
||||
```
|
||||
|
||||
Classic-Diagnose:
|
||||
|
||||
```javascript
|
||||
typeof window.CustomBrandingClassic
|
||||
window.CustomBrandingClassic.reload()
|
||||
```
|
||||
|
||||
## Upgrade von 1.x
|
||||
|
||||
1. Paket im App Catalog durch Version `3.0.1.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.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# CustomBranding – Umsetzungsstand
|
||||
|
||||
Analysestand: 20.07.2026
|
||||
Umgesetzt: 20.07.2026
|
||||
Ausgangsversion: 1.0.4
|
||||
Zielversion: 3.0.5
|
||||
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 3.0.1 kann die zentrale Action erkennen, bearbeiten und bei Bedarf beim ersten Speichern anlegen.
|
||||
|
||||
## 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] Den bisherigen Portal-Settings-Link als sicheren Standard-Footer in der Runtime verankert.
|
||||
- [x] Versionierten, rein deklarativen PortalSettings-Vertragsmarker ergänzt.
|
||||
- [x] Runtime, Standardkonfiguration und Deployment-Skript auf denselben Providervertrag gebracht.
|
||||
- [x] Direkte Aktivierung aus PortalSettings 3.0.1 dokumentiert.
|
||||
|
||||
## 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] CustomBranding mit Sequence 90 vor MegaMenu (100) und Current Navigation (110) registriert.
|
||||
- [x] Top-Branding direkt als Geschwister von `CustomNavigation` in `CustomHeader` integriert; Bottom-Host bleibt separat.
|
||||
- [x] Renderer verändert ausschließlich eigene, per Ownership-Attribut markierte Top-Knoten und Bottom-Host-Inhalte.
|
||||
- [x] Wiederholtes Rendern löscht `CustomNavigation` 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
@@ -0,0 +1,309 @@
|
||||
/* CustomBranding 3.0.5 - 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', 'form', 'input', '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; }
|
||||
if (tag === 'form') { return ['action', 'method'].indexOf(name) >= 0; }
|
||||
if (tag === 'input') { return ['type', 'name', 'placeholder', 'autocomplete', 'maxlength'].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' || name === 'action') {
|
||||
var safeUrl = sanitizeUrl(value, name === 'href');
|
||||
if (safeUrl && safeUrl.indexOf('//') !== 0) { element.setAttribute(name, safeUrl); }
|
||||
} else if (name === 'target') {
|
||||
if (value === '_blank' || value === '_self') { element.setAttribute(name, value); }
|
||||
} else if (name === 'type' && tag === 'button') {
|
||||
element.setAttribute('type', value.toLowerCase() === 'submit' ? 'submit' : 'button');
|
||||
} else if (name === 'type' && tag === 'input') {
|
||||
element.setAttribute('type', 'search');
|
||||
} else if (name === 'method' && tag === 'form') {
|
||||
element.setAttribute('method', 'get');
|
||||
} else if (name === 'name' && tag === 'input') {
|
||||
if (/^[a-z0-9_-]{1,64}$/i.test(value)) { element.setAttribute('name', value); }
|
||||
} else if (name === 'autocomplete' && tag === 'input') {
|
||||
if (value === 'on' || value === 'off') { element.setAttribute('autocomplete', value); }
|
||||
} else if (name === 'maxlength' && tag === 'input') {
|
||||
if (/^\d{1,4}$/.test(value)) { element.setAttribute('maxlength', 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 === 'form' && !element.getAttribute('action')) { return null; }
|
||||
if (tag === 'form') { element.setAttribute('method', 'get'); }
|
||||
if (tag === 'input') { element.setAttribute('type', 'search'); element.setAttribute('name', element.getAttribute('name') || 'q'); }
|
||||
if (tag === 'button' && element.getAttribute('type') !== 'submit') { 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' && tag !== 'input' && 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); }
|
||||
}
|
||||
host.style.fontFamily = '"Segoe UI", "Segoe UI Web (West European)", Arial, sans-serif';
|
||||
host.style.fontSize = '14px';
|
||||
host.style.color = '#323130';
|
||||
host.style.borderTop = /BottomHost$/.test(id) ? '1px solid #edebe9' : '';
|
||||
host.style.borderBottom = /TopHost$/.test(id) ? '1px solid #edebe9' : '';
|
||||
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 : {};
|
||||
var configuredBottom = raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements)
|
||||
? raw.placeholderbottom.elements
|
||||
: [];
|
||||
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: configuredBottom.length > 0 ? configuredBottom : defaultFooterElements()
|
||||
};
|
||||
}
|
||||
|
||||
function defaultFooterElements() {
|
||||
return [{
|
||||
type: 'div',
|
||||
attributes: { role: 'contentinfo', 'aria-label': 'Portalverwaltung' },
|
||||
styles: {
|
||||
padding: '8px 16px', 'text-align': 'right', width: '100%', 'box-sizing': 'border-box'
|
||||
},
|
||||
children: [{
|
||||
type: 'a',
|
||||
attributes: { href: '~sitecollection/SitePages/PortalSettings.aspx' },
|
||||
children: [{
|
||||
type: 'span',
|
||||
attributes: { 'class': 'ms-Icon ms-Icon--Settings', 'aria-hidden': 'true' },
|
||||
styles: { 'margin-right': '4px' }
|
||||
}, {
|
||||
type: 'span', content: 'Portal Settings'
|
||||
}]
|
||||
}]
|
||||
}];
|
||||
}
|
||||
|
||||
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.5.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"
|
||||
|
||||
+19
-137
@@ -9,158 +9,40 @@
|
||||
"035ba968-6488-4d42-86b3-0470ffcc95b9": {
|
||||
"location": "ClientSideExtension.ApplicationCustomizer",
|
||||
"properties": {
|
||||
"schemaVersion": 2,
|
||||
"enabled": true,
|
||||
"debug": true,
|
||||
"allowedCssHosts": [],
|
||||
"cssfiles": [],
|
||||
"placeholdertop": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "div",
|
||||
"type": "section",
|
||||
"attributes": {
|
||||
"aria-label": "Portalhinweis"
|
||||
},
|
||||
"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",
|
||||
"color": "#ffffff",
|
||||
"padding": "12px 20px",
|
||||
"text-align": "center"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "strong",
|
||||
"content": "Willkommen im SharePoint-Portal"
|
||||
},
|
||||
{
|
||||
"type": "span",
|
||||
"content": "Wichtige Mitteilung: Systemwartung geplant. ",
|
||||
"styles": {
|
||||
"font-weight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "a",
|
||||
"content": "Mehr Informationen",
|
||||
"attributes": {
|
||||
"href": "/sites/it/SitePages/Wartung.aspx",
|
||||
"target": "_blank"
|
||||
},
|
||||
"styles": {
|
||||
"color": "white",
|
||||
"text-decoration": "underline",
|
||||
"margin-left": "5px"
|
||||
}
|
||||
"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,224 @@ 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
|
||||
function New-DefaultFooterElements {
|
||||
return @(@{
|
||||
type = 'div'
|
||||
attributes = @{ role = 'contentinfo'; 'aria-label' = 'Portalverwaltung' }
|
||||
styles = @{
|
||||
padding = '8px 16px'; 'text-align' = 'right'; width = '100%'; 'box-sizing' = 'border-box'
|
||||
}
|
||||
children = @(@{
|
||||
type = 'a'
|
||||
attributes = @{ href = '~sitecollection/SitePages/PortalSettings.aspx' }
|
||||
children = @(
|
||||
@{
|
||||
type = 'span'
|
||||
attributes = @{ class = 'ms-Icon ms-Icon--Settings'; 'aria-hidden' = 'true' }
|
||||
styles = @{ 'margin-right' = '4px' }
|
||||
},
|
||||
@{ type = 'span'; content = 'Portal Settings' }
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
$config = @{
|
||||
function New-DefaultCustomBrandingConfiguration {
|
||||
return @{
|
||||
schemaVersion = 2
|
||||
portalSettings = @{
|
||||
providerKey = 'custombranding'
|
||||
contractVersion = 1
|
||||
minimumPortalSettingsVersion = '3.0.0'
|
||||
}
|
||||
enabled = $true
|
||||
debug = $false
|
||||
allowedCssHosts = @()
|
||||
cssfiles = @()
|
||||
placeholdertop = @{ elements = @() }
|
||||
placeholderbottom = @{ elements = @() }
|
||||
placeholderbottom = @{ elements = @(New-DefaultFooterElements) }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
$configuration.portalSettings = @{
|
||||
providerKey = 'custombranding'
|
||||
contractVersion = 1
|
||||
minimumPortalSettingsVersion = '3.0.0'
|
||||
}
|
||||
if (-not $configuration.ContainsKey('placeholdertop')) { $configuration.placeholdertop = @{ elements = @() } }
|
||||
if (-not $configuration.ContainsKey('placeholderbottom')) {
|
||||
$configuration.placeholderbottom = @{ elements = @(New-DefaultFooterElements) }
|
||||
}
|
||||
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 = 90
|
||||
$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() }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Custom Branding und Mega Menu sind direkte Geschwister in #CustomHeader:
|
||||
*
|
||||
* #CustomHeader
|
||||
* .system-ndzk-bar
|
||||
* .portal-branding | #CustomNavigation
|
||||
*/
|
||||
|
||||
#CustomHeader {
|
||||
align-items: stretch;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif;
|
||||
grid-template-areas:
|
||||
"system system"
|
||||
"branding navigation";
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
grid-template-rows: 28px 309px;
|
||||
gap: 0;
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#CustomHeader > .system-ndzk-bar {
|
||||
align-items: center;
|
||||
background: #c50f1f;
|
||||
box-sizing: border-box;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
grid-area: system;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
line-height: 20px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
#CustomHeader > .portal-branding {
|
||||
grid-area: branding;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#CustomHeader > #CustomNavigation {
|
||||
align-items: stretch;
|
||||
align-self: start;
|
||||
border-bottom: 0 !important;
|
||||
border-top: 0 !important;
|
||||
display: flex;
|
||||
grid-area: navigation;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
--megaMenuBarHeight: 40px;
|
||||
}
|
||||
|
||||
#CustomHeader > #CustomNavigation > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#CustomHeader > #CustomNavigation .mega-menu-main {
|
||||
background: #003d79;
|
||||
border-top: 0 !important;
|
||||
color: #ffffff;
|
||||
height: 40px;
|
||||
margin-top: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
#CustomHeader > #CustomNavigation .mega-menu-top-level,
|
||||
#CustomHeader > #CustomNavigation .menu-item-link,
|
||||
#CustomHeader > #CustomNavigation .menu-item-text,
|
||||
#CustomHeader > #CustomNavigation .menu-item-toggle {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#CustomHeader > #CustomNavigation .mega-menu-top-item:hover,
|
||||
#CustomHeader > #CustomNavigation .mega-menu-top-item:focus-within,
|
||||
#CustomHeader > #CustomNavigation .mega-menu-top-item:active,
|
||||
#CustomHeader > #CustomNavigation .mega-menu-top-item.is-open,
|
||||
#CustomHeader > #CustomNavigation .mega-menu-top-item.is-active {
|
||||
background: #0f548c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Das geöffnete Mega Menu nutzt auch die 240-px-Fläche unterhalb des Logos. */
|
||||
#CustomHeader > #CustomNavigation .mega-menu {
|
||||
left: -240px;
|
||||
}
|
||||
|
||||
#CustomHeader > #CustomNavigation .mega-menu-grid {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Eigene Suche im freien Bereich rechts neben dem Logo. */
|
||||
#CustomHeader > #CustomBrandingSearch {
|
||||
align-self: start;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
justify-self: end;
|
||||
margin: 64px 2px 0 0;
|
||||
width: 272px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.custom-branding-search-form {
|
||||
align-items: stretch;
|
||||
background: #ffffff;
|
||||
border: 1px solid #8a8886;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-branding-search-form:focus-within {
|
||||
border-color: #605e5c;
|
||||
box-shadow: inset 0 0 0 1px #605e5c;
|
||||
}
|
||||
|
||||
.custom-branding-search-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
color: #323130;
|
||||
flex: 1 1 auto;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
outline: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.custom-branding-search-input::placeholder {
|
||||
color: #605e5c;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.custom-branding-search-button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #005a9e;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 0 0 40px;
|
||||
font-size: 16px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-branding-search-button:hover,
|
||||
.custom-branding-search-button:focus {
|
||||
background: #f3f2f1;
|
||||
color: #004578;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.custom-branding-search-button:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px #605e5c;
|
||||
}
|
||||
|
||||
.ms-compositeHeader .ms-compositeHeader-searchBoxContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.portal-branding {
|
||||
align-items: flex-start;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 220px;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.portal-branding-home {
|
||||
align-items: flex-start;
|
||||
color: #323130;
|
||||
display: block;
|
||||
width: 240px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-branding-home:hover {
|
||||
color: #201f1e;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-branding-home:focus-visible {
|
||||
outline: 2px solid #605e5c;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.portal-branding-logo {
|
||||
display: block;
|
||||
height: 220px;
|
||||
object-fit: contain;
|
||||
width: 240px;
|
||||
background-image: url(http://portal.contoso.local/Branding/Images/logo-background.png);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.ms-compositeHeader {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
#CustomHeader,
|
||||
.portal-branding,
|
||||
.system-ndzk-bar {
|
||||
border-color: CanvasText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"portalSettings": {
|
||||
"providerKey": "custombranding",
|
||||
"contractVersion": 1,
|
||||
"minimumPortalSettingsVersion": "3.0.0"
|
||||
},
|
||||
"enabled": true,
|
||||
"debug": false,
|
||||
"allowedCssHosts": [],
|
||||
"cssfiles": [
|
||||
{
|
||||
"path": "http://portal.contoso.local/SiteAssets/branding/custom-branding.css",
|
||||
"media": "all"
|
||||
}
|
||||
],
|
||||
"placeholdertop": {
|
||||
"elements": [
|
||||
{
|
||||
"type": "div",
|
||||
"content": "System NDzK",
|
||||
"attributes": {
|
||||
"class": "system-ndzk-bar",
|
||||
"role": "note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "div",
|
||||
"attributes": {
|
||||
"class": "portal-branding",
|
||||
"role": "banner",
|
||||
"aria-label": "Portal-Kopfbereich"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "a",
|
||||
"attributes": {
|
||||
"class": "portal-branding-home",
|
||||
"href": "http://portal.contoso.local/SitePages/Home.aspx",
|
||||
"title": "Zur Portal-Startseite"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "img",
|
||||
"attributes": {
|
||||
"class": "portal-branding-logo",
|
||||
"src": "http://portal.contoso.local/Branding/Images/logo.png",
|
||||
"alt": "Zentrale Abbildende Aufklärung"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"attributes": {
|
||||
"id": "CustomBrandingSearch",
|
||||
"class": "custom-branding-search-form",
|
||||
"role": "search",
|
||||
"aria-label": "Diese Website durchsuchen",
|
||||
"action": "~sitecollection/_layouts/15/search.aspx/siteall",
|
||||
"method": "get"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "input",
|
||||
"attributes": {
|
||||
"class": "custom-branding-search-input",
|
||||
"type": "search",
|
||||
"name": "q",
|
||||
"placeholder": "Diese Website durchsuchen",
|
||||
"autocomplete": "off",
|
||||
"maxlength": "500",
|
||||
"aria-label": "Diese Website durchsuchen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"attributes": {
|
||||
"class": "custom-branding-search-button",
|
||||
"type": "submit",
|
||||
"title": "Suchen",
|
||||
"aria-label": "Suchen"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "span",
|
||||
"attributes": {
|
||||
"class": "ms-Icon ms-Icon--Search",
|
||||
"aria-hidden": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"placeholderbottom": {
|
||||
"elements": []
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "custom-branding",
|
||||
"version": "1.0.4",
|
||||
"version": "3.0.5",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
+8
-7
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "custom-branding",
|
||||
"version": "1.0.4",
|
||||
"version": "3.0.5",
|
||||
"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>
|
||||
@@ -0,0 +1,361 @@
|
||||
// tslint:disable:no-any max-line-length
|
||||
import {
|
||||
BrandingElementType,
|
||||
IBrandingElement,
|
||||
IBrandingNormalizationResult,
|
||||
ICssFile,
|
||||
ICustomBrandingApplicationCustomizerProperties,
|
||||
ICustomBrandingConfig,
|
||||
IPortalSettingsDescriptor
|
||||
} 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', 'form', 'input', '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(siteCollectionUrl), 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 configuredBottomSource: any[] = raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements)
|
||||
? raw.placeholderbottom.elements
|
||||
: [];
|
||||
const bottomSource: any[] = configuredBottomSource.length > 0
|
||||
? configuredBottomSource
|
||||
: createDefaultFooterElements();
|
||||
|
||||
const config: ICustomBrandingConfig = {
|
||||
schemaVersion: BrandingSchemaVersion,
|
||||
portalSettings: createPortalSettingsDescriptor(),
|
||||
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(siteCollectionUrl: string): ICustomBrandingConfig {
|
||||
const state: INormalizationState = { count: 0, warnings: [], siteCollectionUrl: siteCollectionUrl || '' };
|
||||
return {
|
||||
schemaVersion: BrandingSchemaVersion,
|
||||
portalSettings: createPortalSettingsDescriptor(),
|
||||
enabled: true,
|
||||
debug: false,
|
||||
allowedCssHosts: [],
|
||||
cssfiles: [],
|
||||
placeholdertop: { elements: [] },
|
||||
placeholderbottom: { elements: normalizeElements(createDefaultFooterElements(), 1, state) }
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultFooterElements(): any[] {
|
||||
return [{
|
||||
type: 'div',
|
||||
attributes: { role: 'contentinfo', 'aria-label': 'Portalverwaltung' },
|
||||
styles: {
|
||||
padding: '8px 16px', 'text-align': 'right', width: '100%', 'box-sizing': 'border-box'
|
||||
},
|
||||
children: [{
|
||||
type: 'a',
|
||||
attributes: { href: '~sitecollection/SitePages/PortalSettings.aspx' },
|
||||
children: [{
|
||||
type: 'span',
|
||||
attributes: { class: 'ms-Icon ms-Icon--Settings', 'aria-hidden': 'true' },
|
||||
styles: { 'margin-right': '4px' }
|
||||
}, {
|
||||
type: 'span', content: 'Portal Settings'
|
||||
}]
|
||||
}]
|
||||
}];
|
||||
}
|
||||
|
||||
function createPortalSettingsDescriptor(): IPortalSettingsDescriptor {
|
||||
return {
|
||||
providerKey: 'custombranding',
|
||||
contractVersion: 1,
|
||||
minimumPortalSettingsVersion: '3.0.0'
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (tag === 'form' && !attributes.action) {
|
||||
state.warnings.push('Form without a safe action URL 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' || tag === 'input'
|
||||
? []
|
||||
: 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'; }
|
||||
if (tag === 'form') { result.method = 'get'; }
|
||||
if (tag === 'input') { result.type = 'search'; result.name = 'q'; }
|
||||
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' || name === 'action') {
|
||||
const safeUrl: string = sanitizeNavigationUrl(rawValue, state.siteCollectionUrl, name === 'href');
|
||||
if (safeUrl && safeUrl.indexOf('//') !== 0) { 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 = rawValue.toLowerCase() === 'submit' ? 'submit' : 'button';
|
||||
} else if (name === 'type' && tag === 'input') {
|
||||
result.type = 'search';
|
||||
} else if (name === 'method' && tag === 'form') {
|
||||
result.method = 'get';
|
||||
} else if (name === 'name' && tag === 'input') {
|
||||
if (/^[a-z0-9_-]{1,64}$/i.test(rawValue)) { result.name = rawValue; }
|
||||
} else if (name === 'autocomplete' && tag === 'input') {
|
||||
if (rawValue === 'on' || rawValue === 'off') { result.autocomplete = rawValue; }
|
||||
} else if (name === 'maxlength' && tag === 'input') {
|
||||
if (/^\d{1,4}$/.test(rawValue)) { result.maxlength = rawValue; }
|
||||
} else {
|
||||
result[name] = rawValue;
|
||||
}
|
||||
}
|
||||
if (tag === 'button' && result.type !== 'submit') { result.type = 'button'; }
|
||||
if (tag === 'form') { result.method = 'get'; }
|
||||
if (tag === 'input') { result.type = 'search'; result.name = result.name || 'q'; }
|
||||
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; }
|
||||
if (tag === 'form') { return ['action', 'method'].indexOf(name) >= 0; }
|
||||
if (tag === 'input') { return ['type', 'name', 'placeholder', 'autocomplete', 'maxlength'].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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export type BrandingElementType = 'div' | 'span' | 'p' | 'a' | 'button' | 'form' | 'input' | '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 IPortalSettingsDescriptor {
|
||||
providerKey: 'custombranding';
|
||||
contractVersion: number;
|
||||
minimumPortalSettingsVersion: string;
|
||||
}
|
||||
|
||||
export interface ICustomBrandingConfig {
|
||||
schemaVersion: number;
|
||||
portalSettings: IPortalSettingsDescriptor;
|
||||
enabled: boolean;
|
||||
debug: boolean;
|
||||
allowedCssHosts: string[];
|
||||
cssfiles: ICssFile[];
|
||||
placeholdertop: IPlaceholderConfig;
|
||||
placeholderbottom: IPlaceholderConfig;
|
||||
}
|
||||
|
||||
export interface ICustomBrandingApplicationCustomizerProperties {
|
||||
schemaVersion?: number;
|
||||
portalSettings?: IPortalSettingsDescriptor;
|
||||
enabled?: boolean;
|
||||
debug?: boolean;
|
||||
allowedCssHosts?: string[];
|
||||
cssfiles?: ICssFile[];
|
||||
placeholdertop?: IPlaceholderConfig;
|
||||
placeholderbottom?: IPlaceholderConfig;
|
||||
elements?: IBrandingElement[];
|
||||
}
|
||||
|
||||
export interface IBrandingNormalizationResult {
|
||||
config: ICustomBrandingConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -1,373 +1,215 @@
|
||||
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 _topElements: HTMLElement[] = [];
|
||||
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');
|
||||
|
||||
// CSS-Dateien laden
|
||||
this._loadCssFiles();
|
||||
|
||||
// Auf Placeholder-Änderungen reagieren
|
||||
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceHolders);
|
||||
|
||||
// Initial rendern
|
||||
this._renderPlaceHolders();
|
||||
const result: IBrandingNormalizationResult = normalizeBrandingConfig(
|
||||
this.properties,
|
||||
this.context.pageContext.site.absoluteUrl
|
||||
);
|
||||
this._config = result.config;
|
||||
this.debug(strings.Initialized + ' 3.0.5.', {
|
||||
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]);
|
||||
}
|
||||
|
||||
if (!this._config.enabled) {
|
||||
this.debug('CustomBranding is disabled by configuration.');
|
||||
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...');
|
||||
|
||||
for (let i = 0; i < this.properties.cssfiles.length; i++) {
|
||||
const cssFile = this.properties.cssfiles[i];
|
||||
|
||||
if (cssFile && cssFile.path) {
|
||||
this._injectCssFile(cssFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
private _renderPlaceholders(): void {
|
||||
if (this._isDisposed || !this._config || !this._config.enabled) { 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.removeLegacyTopHost(this._topPlaceholder.domElement);
|
||||
this.renderTopSafely(this._topPlaceholder.domElement, 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);
|
||||
}
|
||||
host.style.fontFamily = '"Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif';
|
||||
host.style.fontSize = '14px';
|
||||
host.style.color = '#323130';
|
||||
host.style.borderTop = id === 'CustomBrandingBottomHost' ? '1px solid #edebe9' : '';
|
||||
host.style.borderBottom = '';
|
||||
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 renderTopSafely(host: HTMLElement, elements: IBrandingElement[]): void {
|
||||
this.clearTopElements();
|
||||
const staging: HTMLElement = document.createElement('div');
|
||||
try {
|
||||
this._renderer.render(staging, elements);
|
||||
} catch (error) {
|
||||
this._renderer.clear(staging);
|
||||
const status: HTMLElement = document.createElement('div');
|
||||
status.className = 'custom-branding-status';
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = strings.RenderError;
|
||||
staging.appendChild(status);
|
||||
this.debug('Top rendering failed.', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt HTML für ein einzelnes Element
|
||||
*/
|
||||
private _createElement(element: IBrandingElement): string {
|
||||
const tag = element.type || 'div';
|
||||
let html = '<' + tag;
|
||||
const insertBefore: Node = host.firstChild;
|
||||
while (staging.firstChild) {
|
||||
const element: HTMLElement = staging.firstChild as HTMLElement;
|
||||
element.setAttribute('data-custom-branding-owner', COMPONENT_ID);
|
||||
element.setAttribute('data-custom-branding-placement', 'top');
|
||||
host.insertBefore(element, insertBefore);
|
||||
this._topElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) + '"';
|
||||
private clearTopElements(): void {
|
||||
for (let i: number = 0; i < this._topElements.length; i++) {
|
||||
const element: HTMLElement = this._topElements[i];
|
||||
if (element.parentNode) { element.parentNode.removeChild(element); }
|
||||
}
|
||||
this._topElements = [];
|
||||
}
|
||||
|
||||
private removeLegacyTopHost(parent: HTMLElement): void {
|
||||
const legacyHost: HTMLElement = parent.querySelector('#CustomBrandingTopHost') as HTMLElement;
|
||||
if (legacyHost && legacyHost.parentNode === parent) {
|
||||
parent.removeChild(legacyHost);
|
||||
}
|
||||
}
|
||||
|
||||
private _onTopPlaceholderDisposed = (): void => {
|
||||
this.clearTopElements();
|
||||
this._topPlaceholder = undefined;
|
||||
this.debug('Top placeholder disposed; waiting for a new placeholder.');
|
||||
}
|
||||
|
||||
private _onBottomPlaceholderDisposed = (): void => {
|
||||
this.removeHost(this._bottomHost);
|
||||
this._bottomHost = undefined;
|
||||
this._bottomPlaceholder = undefined;
|
||||
this.debug('Bottom placeholder disposed; waiting for a new placeholder.');
|
||||
}
|
||||
|
||||
@override
|
||||
protected onDispose(): void {
|
||||
if (this._isDisposed) { return; }
|
||||
this._isDisposed = true;
|
||||
this.context.placeholderProvider.changedEvent.remove(this, this._renderPlaceholders);
|
||||
this.clearTopElements();
|
||||
this.removeHost(this._bottomHost);
|
||||
if (this._cssLoader) {
|
||||
this._cssLoader.dispose();
|
||||
}
|
||||
this._bottomHost = undefined;
|
||||
this._topPlaceholder = undefined;
|
||||
this._bottomPlaceholder = undefined;
|
||||
this._cssLoader = undefined;
|
||||
this.debug('CustomBranding disposed.');
|
||||
this._config = undefined;
|
||||
}
|
||||
|
||||
private removeHost(host: HTMLElement | undefined): void {
|
||||
if (host && host.parentNode) {
|
||||
host.parentNode.removeChild(host);
|
||||
}
|
||||
}
|
||||
|
||||
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 || '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styles hinzufügen
|
||||
if (element.styles) {
|
||||
const styleArray: string[] = [];
|
||||
for (const key in element.styles) {
|
||||
if (element.styles.hasOwnProperty(key)) {
|
||||
const value = element.styles[key];
|
||||
styleArray.push(key + ':' + value);
|
||||
}
|
||||
}
|
||||
if (styleArray.length > 0) {
|
||||
const styleString = styleArray.join(';');
|
||||
html += ' style="' + styleString + '"';
|
||||
}
|
||||
}
|
||||
|
||||
html += '>';
|
||||
|
||||
// Content hinzufügen
|
||||
if (element.content) {
|
||||
html += this._escapeHtml(element.content);
|
||||
}
|
||||
|
||||
// Kinder hinzufügen
|
||||
if (element.children && Array.isArray(element.children)) {
|
||||
for (let i = 0; i < element.children.length; i++) {
|
||||
html += this._createElement(element.children[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Self-closing Tags behandeln
|
||||
const selfClosingTags = ['img', 'br', 'hr', 'input'];
|
||||
let isSelfClosing = false;
|
||||
for (let i = 0; i < selfClosingTags.length; i++) {
|
||||
if (selfClosingTags[i] === tag) {
|
||||
isSelfClosing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSelfClosing) {
|
||||
html += '</' + tag + '>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private _isSiteAdmin(): boolean {
|
||||
return this.context.pageContext.legacyPageContext.isSiteAdmin === true;
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export {
|
||||
IBrandingElement,
|
||||
ICssFile,
|
||||
ICustomBrandingApplicationCustomizerProperties,
|
||||
IPlaceholderConfig
|
||||
} from './BrandingTypes';
|
||||
|
||||
@@ -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' {
|
||||
|
||||
+3
-1
@@ -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';
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'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.portalSettings.providerKey === 'custombranding', 'PortalSettings Provider-Key fehlt.');
|
||||
assert(empty.config.portalSettings.contractVersion === 1, 'PortalSettings Contract-Version fehlt.');
|
||||
assert(empty.config.enabled === true, 'Branding muss standardmaessig aktiviert sein.');
|
||||
assert(empty.config.placeholderbottom.elements.length === 1, 'Der Standard-Footer fehlt.');
|
||||
var defaultFooterLink = empty.config.placeholderbottom.elements[0].children[0];
|
||||
assert(defaultFooterLink.children[1].content === 'Portal Settings',
|
||||
'Der Standard-Footer enthaelt nicht den Portal-Settings-Link.');
|
||||
assert(defaultFooterLink.attributes.href ===
|
||||
siteUrl + '/SitePages/PortalSettings.aspx', 'Der Standard-Footer verweist nicht auf PortalSettings.');
|
||||
assert(defaultFooterLink.children[0].attributes.class === 'ms-Icon ms-Icon--Settings',
|
||||
'Das Settings-Icon fehlt im Standard-Footer.');
|
||||
assert(empty.config.placeholderbottom.elements[0].styles['text-align'] === 'right',
|
||||
'Der Standard-Footer muss rechtsbuendig sein.');
|
||||
assert(empty.config.placeholderbottom.elements[0].styles['background-color'] === undefined &&
|
||||
defaultFooterLink.styles === undefined,
|
||||
'Der Standard-Footer darf Hintergrund und Standardtypografie nicht ueberschreiben.');
|
||||
|
||||
var emptyBottom = normalize({ placeholderbottom: { elements: [] } }, siteUrl);
|
||||
assert(emptyBottom.config.placeholderbottom.elements.length === 1,
|
||||
'Ein geleertes Footer-Array muss auf den Standard-Footer zurueckfallen.');
|
||||
|
||||
var customBottom = normalize({
|
||||
placeholderbottom: { elements: [{ type: 'span', content: 'Eigener Footer' }] }
|
||||
}, siteUrl);
|
||||
assert(customBottom.config.placeholderbottom.elements.length === 1 &&
|
||||
customBottom.config.placeholderbottom.elements[0].content === 'Eigener Footer',
|
||||
'Ein eigener Footer muss den Standard-Footer ersetzen.');
|
||||
|
||||
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', 'form', 'input', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']
|
||||
.forEach(function (tag) {
|
||||
var attributes = tag === 'img' ? { alt: '' } : (tag === 'form' ? { action: '/search' } : 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 searchForm = element({
|
||||
type: 'form',
|
||||
attributes: { action: '~sitecollection/_layouts/15/search.aspx/siteall', method: 'post' },
|
||||
children: [
|
||||
{ type: 'input', attributes: { type: 'text', name: 'q', placeholder: 'Suchen', autocomplete: 'off' } },
|
||||
{ type: 'button', attributes: { type: 'submit', 'aria-label': 'Suchen' } }
|
||||
]
|
||||
});
|
||||
assert(searchForm.attributes.action === siteUrl + '/_layouts/15/search.aspx/siteall' && searchForm.attributes.method === 'get',
|
||||
'Suchformulare muessen eine sichere GET-Action verwenden.');
|
||||
assert(searchForm.children[0].attributes.type === 'search' && searchForm.children[0].attributes.name === 'q',
|
||||
'Suchfelder muessen auf type=search begrenzt sein.');
|
||||
assert(searchForm.children[1].attributes.type === 'submit', 'Submit-Buttons in Suchformularen muessen erhalten bleiben.');
|
||||
assert(!element({ type: 'form', attributes: { action: 'javascript:alert(1)' } }),
|
||||
'Formulare mit unsicherer Action muessen verworfen werden.');
|
||||
assert(!element({ type: 'form', attributes: { action: '//evil.example.org/search' } }),
|
||||
'Formulare mit protokollrelativer Action 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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
@@ -0,0 +1,59 @@
|
||||
'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 configSource = read('src/extensions/customBranding/BrandingConfig.ts');
|
||||
var rendererSource = read('src/extensions/customBranding/BrandingDomRenderer.ts');
|
||||
var classicSource = read('classic/custom-branding-classic.js');
|
||||
var deploymentSource = read('deployment/add-custombranding.ps1');
|
||||
|
||||
assert(packageJson.version === '3.0.5', 'package.json hat nicht Version 3.0.5.');
|
||||
assert(solution.version === '3.0.5.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(configSource.indexOf("'form', 'input'") >= 0 && configSource.indexOf("result.method = 'get'") >= 0,
|
||||
'Sichere deklarative Suchformulare sind nicht freigeschaltet.');
|
||||
assert(classicSource.indexOf('innerHTML') < 0, 'Die Classic-Runtime darf innerHTML nicht verwenden.');
|
||||
assert(classicSource.indexOf('~sitecollection/SitePages/PortalSettings.aspx') >= 0,
|
||||
'Der Standard-Footer fehlt in der Classic-Runtime.');
|
||||
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("'CustomBrandingBottomHost'") >= 0 &&
|
||||
appSource.indexOf("'data-custom-branding-placement', 'top'") >= 0,
|
||||
'Bottom-Host oder direkte Top-Elemente fehlen.');
|
||||
assert(appSource.indexOf('renderTopSafely(this._topPlaceholder.domElement') >= 0,
|
||||
'Top-Branding wird nicht direkt in CustomHeader gerendert.');
|
||||
assert(deploymentSource.indexOf('$action.Sequence = 90') >= 0,
|
||||
'CustomBranding muss vor MegaMenu geladen werden.');
|
||||
assert(appSource.indexOf("'1px solid #edebe9'") >= 0 && appSource.indexOf('Segoe UI Web') >= 0,
|
||||
'SharePoint-Standardschrift und neutraler Host-Rahmen 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