feat: Add custom branding functionality with CSS and JSON configuration
- Introduced custom branding CSS styles in `custom-branding.css`. - Created example JSON configuration for custom branding in `custom-branding.example.json`. - Implemented branding configuration logic in `BrandingConfig.ts` to normalize and validate branding settings. - Developed CSS loader to manage loading and unloading of custom stylesheets in `BrandingCssLoader.ts`. - Added DOM rendering capabilities for branding elements in `BrandingDomRenderer.ts`. - Defined types and interfaces for branding elements and configurations in `BrandingTypes.ts`. - Included localization support for German in `de-de.js`. - Added unit tests for branding configuration, CSS loader, and DOM renderer. - Validated project structure and static assets with new validation scripts.
This commit is contained in:
263
classic/custom-branding-classic.js
Normal file
263
classic/custom-branding-classic.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/* CustomBranding 2.0.0 - safe Classic SharePoint runtime */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var COMPONENT_ID = '035ba968-6488-4d42-86b3-0470ffcc95b9';
|
||||
var OWNER = 'CustomBranding.Classic';
|
||||
var MAX_DEPTH = 8;
|
||||
var MAX_ELEMENTS = 200;
|
||||
var allowedTags = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
|
||||
var allowedStyles = ('align-items background background-color border border-bottom border-color border-left border-radius border-right border-style border-top border-width box-sizing color display flex flex-basis flex-direction flex-grow flex-shrink flex-wrap font-family font-size font-style font-weight gap grid-template-columns height justify-content line-height margin margin-bottom margin-left margin-right margin-top max-height max-width min-height min-width opacity overflow padding padding-bottom padding-left padding-right padding-top text-align text-decoration text-transform white-space width').split(' ');
|
||||
var state = { debug: false, hosts: [], css: [] };
|
||||
|
||||
function log(message, data) {
|
||||
if (state.debug && global.console && console.log) {
|
||||
console.log('[CustomBranding Classic] ' + message, data || '');
|
||||
}
|
||||
}
|
||||
|
||||
function strings() {
|
||||
var german = global._spPageContextInfo && Number(_spPageContextInfo.currentLanguage) === 1031;
|
||||
return german ? {
|
||||
loadError: 'Die CustomBranding-Konfiguration konnte nicht geladen werden.',
|
||||
renderError: 'Das konfigurierte Branding konnte nicht dargestellt werden.'
|
||||
} : {
|
||||
loadError: 'The CustomBranding configuration could not be loaded.',
|
||||
renderError: 'The configured branding could not be rendered.'
|
||||
};
|
||||
}
|
||||
|
||||
function siteUrl() {
|
||||
return global._spPageContextInfo ? _spPageContextInfo.siteAbsoluteUrl : '';
|
||||
}
|
||||
|
||||
function sanitizeUrl(value, allowMailto) {
|
||||
var raw = String(value || '').replace(/^\s+|\s+$/g, '');
|
||||
if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) { return null; }
|
||||
var resolved = raw.toLowerCase().indexOf('~sitecollection') === 0
|
||||
? siteUrl().replace(/\/+$/, '') + raw.substring('~sitecollection'.length)
|
||||
: raw;
|
||||
var match = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
|
||||
if (!match) { return resolved; }
|
||||
var protocol = match[1].toLowerCase();
|
||||
return protocol === 'http' || protocol === 'https' || (allowMailto && protocol === 'mailto') ? resolved : null;
|
||||
}
|
||||
|
||||
function normalizeHosts(value) {
|
||||
var result = [];
|
||||
if (!Array.isArray(value)) { return result; }
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
var host = String(value[i] || '').toLowerCase().replace(/^\s+|\s+$/g, '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||
if (/^[a-z0-9.-]+(?::\d+)?$/.test(host) && result.indexOf(host) < 0) { result.push(host); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeCssUrl(value, hosts) {
|
||||
var resolved = sanitizeUrl(value, false);
|
||||
if (!resolved) { return null; }
|
||||
var absolute = resolved.match(/^(https?):\/\/([^/]+)/i);
|
||||
if (!absolute) { return resolved; }
|
||||
var current = siteUrl().match(/^(https?):\/\/([^/]+)/i);
|
||||
var protocol = absolute[1].toLowerCase();
|
||||
var host = absolute[2].toLowerCase();
|
||||
if (current && protocol === current[1].toLowerCase() && host === current[2].toLowerCase()) { return resolved; }
|
||||
return protocol === 'https' && hosts.indexOf(host) >= 0 ? resolved : null;
|
||||
}
|
||||
|
||||
function sanitizeStyle(name, value) {
|
||||
var property = String(name || '').toLowerCase().replace(/^\s+|\s+$/g, '');
|
||||
var styleValue = String(value || '').replace(/^\s+|\s+$/g, '');
|
||||
if (allowedStyles.indexOf(property) < 0 || !styleValue || styleValue.length > 512) { return null; }
|
||||
if (/[\u0000-\u001f\u007f]/.test(styleValue) || /(url\s*\(|expression\s*\(|javascript\s*:|@import|behavior\s*:|-moz-binding)/i.test(styleValue)) { return null; }
|
||||
return styleValue;
|
||||
}
|
||||
|
||||
function allowedAttribute(tag, name) {
|
||||
if (['id', 'class', 'title', 'role', 'aria-label', 'aria-hidden', 'aria-current', 'aria-live'].indexOf(name) >= 0) { return true; }
|
||||
if (tag === 'a') { return ['href', 'target'].indexOf(name) >= 0; }
|
||||
if (tag === 'img') { return ['src', 'alt', 'width', 'height'].indexOf(name) >= 0; }
|
||||
if (tag === 'button') { return ['type', 'disabled', 'aria-expanded', 'aria-controls'].indexOf(name) >= 0; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function createElement(config, depth, counter) {
|
||||
if (!config || typeof config !== 'object' || depth > MAX_DEPTH || counter.value >= MAX_ELEMENTS) { return null; }
|
||||
var tag = String(config.type || '').toLowerCase();
|
||||
if (allowedTags.indexOf(tag) < 0) { return null; }
|
||||
counter.value++;
|
||||
var element = document.createElement(tag);
|
||||
var attributes = config.attributes && typeof config.attributes === 'object' ? config.attributes : {};
|
||||
var hasAlt = false;
|
||||
for (var rawName in attributes) {
|
||||
if (!Object.prototype.hasOwnProperty.call(attributes, rawName)) { continue; }
|
||||
var name = String(rawName).toLowerCase();
|
||||
var value = String(attributes[rawName] === undefined ? '' : attributes[rawName]).substring(0, 2048);
|
||||
if (name.indexOf('on') === 0 || !allowedAttribute(tag, name)) { continue; }
|
||||
if (name === 'href' || name === 'src') {
|
||||
var safeUrl = sanitizeUrl(value, name === 'href');
|
||||
if (safeUrl) { element.setAttribute(name, safeUrl); }
|
||||
} else if (name === 'target') {
|
||||
if (value === '_blank' || value === '_self') { element.setAttribute(name, value); }
|
||||
} else if ((name === 'id' || name === 'class') && !/^[a-z0-9 _-]{1,256}$/i.test(value)) {
|
||||
continue;
|
||||
} else if ((name === 'width' || name === 'height') && !/^\d{1,4}$/.test(value)) {
|
||||
continue;
|
||||
} else {
|
||||
element.setAttribute(name, value);
|
||||
}
|
||||
if (name === 'alt') { hasAlt = true; }
|
||||
}
|
||||
if (tag === 'img' && !hasAlt) { return null; }
|
||||
if (tag === 'button') { element.setAttribute('type', 'button'); }
|
||||
if (tag === 'a' && element.getAttribute('target') === '_blank') { element.setAttribute('rel', 'noopener noreferrer'); }
|
||||
|
||||
var styles = config.styles && typeof config.styles === 'object' ? config.styles : {};
|
||||
for (var styleName in styles) {
|
||||
if (!Object.prototype.hasOwnProperty.call(styles, styleName)) { continue; }
|
||||
var safeStyle = sanitizeStyle(styleName, styles[styleName]);
|
||||
if (safeStyle) { element.style.setProperty(String(styleName).toLowerCase(), safeStyle); }
|
||||
}
|
||||
if (config.content !== undefined && config.content !== null) {
|
||||
element.appendChild(document.createTextNode(String(config.content).substring(0, 4000)));
|
||||
}
|
||||
if (tag !== 'img' && Array.isArray(config.children)) {
|
||||
for (var i = 0; i < config.children.length; i++) {
|
||||
var child = createElement(config.children[i], depth + 1, counter);
|
||||
if (child) { element.appendChild(child); }
|
||||
}
|
||||
}
|
||||
if ((tag === 'a' || tag === 'button') && !element.textContent && !element.getAttribute('aria-label')) { return null; }
|
||||
return element;
|
||||
}
|
||||
|
||||
function clear(element) {
|
||||
while (element && element.firstChild) { element.removeChild(element.firstChild); }
|
||||
}
|
||||
|
||||
function renderHost(id, parent, elements, beforeNode) {
|
||||
var host = document.getElementById(id);
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = id;
|
||||
host.setAttribute('data-custom-branding-owner', OWNER);
|
||||
if (beforeNode) { parent.insertBefore(host, beforeNode); } else { parent.appendChild(host); }
|
||||
}
|
||||
clear(host);
|
||||
var counter = { value: 0 };
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var element = createElement(elements[i], 1, counter);
|
||||
if (element) { host.appendChild(element); }
|
||||
}
|
||||
state.hosts.push(host);
|
||||
}
|
||||
|
||||
function showStatus(message) {
|
||||
var host = document.getElementById('CustomBrandingClassicTopHost') || document.createElement('div');
|
||||
host.id = 'CustomBrandingClassicTopHost';
|
||||
clear(host);
|
||||
var status = document.createElement('div');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = message;
|
||||
host.appendChild(status);
|
||||
if (!host.parentNode) { document.body.insertBefore(host, document.body.firstChild); }
|
||||
}
|
||||
|
||||
function loadCss(files, hosts) {
|
||||
var seen = {};
|
||||
for (var i = 0; i < files.length && i < 20; i++) {
|
||||
var path = sanitizeCssUrl(files[i] && files[i].path, hosts);
|
||||
var key = String(path || '').toLowerCase();
|
||||
if (!path || seen[key]) { continue; }
|
||||
seen[key] = true;
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = path;
|
||||
link.media = files[i].media && /^[a-z0-9 (),.:\/-]{1,80}$/i.test(files[i].media)
|
||||
? files[i].media
|
||||
: 'all';
|
||||
link.setAttribute('data-custom-branding-owner', OWNER);
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
state.css.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(raw) {
|
||||
raw = raw && typeof raw === 'object' ? raw : {};
|
||||
return {
|
||||
enabled: raw.enabled !== false,
|
||||
debug: raw.debug === true,
|
||||
allowedCssHosts: normalizeHosts(raw.allowedCssHosts),
|
||||
cssfiles: Array.isArray(raw.cssfiles) ? raw.cssfiles : [],
|
||||
top: raw.placeholdertop && Array.isArray(raw.placeholdertop.elements) ? raw.placeholdertop.elements : (Array.isArray(raw.elements) ? raw.elements : []),
|
||||
bottom: raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements) ? raw.placeholderbottom.elements : []
|
||||
};
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
for (var i = 0; i < state.hosts.length; i++) {
|
||||
if (state.hosts[i].parentNode) { state.hosts[i].parentNode.removeChild(state.hosts[i]); }
|
||||
}
|
||||
for (var j = 0; j < state.css.length; j++) {
|
||||
if (state.css[j].parentNode) { state.css[j].parentNode.removeChild(state.css[j]); }
|
||||
}
|
||||
state.hosts = [];
|
||||
state.css = [];
|
||||
}
|
||||
|
||||
function render(config) {
|
||||
cleanup();
|
||||
state.debug = config.debug;
|
||||
if (!config.enabled) { return; }
|
||||
loadCss(config.cssfiles, config.allowedCssHosts);
|
||||
var titleRow = document.getElementById('s4-titlerow');
|
||||
var topParent = titleRow && titleRow.parentNode ? titleRow.parentNode : document.body;
|
||||
var topBefore = titleRow ? titleRow.nextSibling : document.body.firstChild;
|
||||
renderHost('CustomBrandingClassicTopHost', topParent, config.top, topBefore);
|
||||
var workspace = document.getElementById('s4-workspace') || document.body;
|
||||
renderHost('CustomBrandingClassicBottomHost', workspace, config.bottom, null);
|
||||
log('Branding rendered.');
|
||||
}
|
||||
|
||||
function readConfiguration(callback, errorCallback) {
|
||||
var url = siteUrl() + "/_api/site/UserCustomActions?$filter=ClientSideComponentId eq guid'" + COMPONENT_ID + "'&$select=ClientSideComponentProperties";
|
||||
var request = new XMLHttpRequest();
|
||||
request.open('GET', url, true);
|
||||
request.setRequestHeader('Accept', 'application/json;odata=verbose');
|
||||
request.onreadystatechange = function () {
|
||||
if (request.readyState !== 4) { return; }
|
||||
if (request.status < 200 || request.status >= 300) { errorCallback(); return; }
|
||||
try {
|
||||
var data = JSON.parse(request.responseText);
|
||||
var actions = data && data.d && data.d.results ? data.d.results : [];
|
||||
var serialized = actions.length && actions[0].ClientSideComponentProperties
|
||||
? actions[0].ClientSideComponentProperties
|
||||
: '{}';
|
||||
if (serialized.length > 100000) { throw new Error('Configuration exceeds the maximum size.'); }
|
||||
var properties = JSON.parse(serialized);
|
||||
callback(normalize(properties));
|
||||
} catch (error) { errorCallback(error); }
|
||||
};
|
||||
request.send();
|
||||
}
|
||||
|
||||
function init() {
|
||||
readConfiguration(render, function (error) {
|
||||
log('Configuration load failed.', error);
|
||||
showStatus(strings().loadError);
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!global._spPageContextInfo) { global.setTimeout(start, 100); return; }
|
||||
init();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
global.setTimeout(start, 0);
|
||||
}
|
||||
|
||||
global.CustomBrandingClassic = { init: init, reload: init, dispose: cleanup };
|
||||
}(window));
|
||||
Reference in New Issue
Block a user