svarog-ui-core
Version:
Core components of Svarog UI without themes
473 lines (403 loc) • 12.4 kB
JavaScript
// src/components/RichText/RichText.js
import {
createElement,
cleanupEventListeners,
} from '../../utils/componentFactory.js';
import {
createStyleInjector,
injectStyles,
} from '../../utils/styleInjection.js';
import { richTextStyles } from './RichText.styles.js';
import { baseVariables } from '../../styles/baseVariables.js';
// Ensure base variables are loaded
if (typeof document !== 'undefined') {
injectStyles('svarog-base-vars', baseVariables, { priority: 'base' });
}
// Create style injector for RichText component
const injectRichTextStyles = createStyleInjector('RichText');
/**
* HTML sanitization utility for XSS protection
*/
const SecurityHelpers = {
// Allowed HTML tags for rich text content
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'div',
'span',
'br',
'strong',
'b',
'em',
'i',
'u',
'ul',
'ol',
'li',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'blockquote',
'pre',
'code',
'hr',
'sub',
'sup',
],
// Allowed attributes for each tag
ALLOWED_ATTRIBUTES: {
a: ['href', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
table: ['class'],
th: ['scope', 'colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
'*': ['class', 'id', 'data-*'],
},
/**
* Sanitize HTML content to prevent XSS attacks
* @param {string} html - HTML string to sanitize
* @returns {string} Sanitized HTML string
*/
sanitizeHTML(html) {
if (!html || typeof html !== 'string') return '';
// Create a temporary DOM element for parsing
const temp = document.createElement('div');
temp.innerHTML = html;
// Recursively clean the DOM tree
this.cleanNode(temp);
return temp.innerHTML;
},
/**
* Recursively clean a DOM node and its children
* @param {Node} node - DOM node to clean
*/
cleanNode(node) {
if (!node) return;
// Process all child nodes
const children = Array.from(node.childNodes);
children.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
const tagName = child.tagName.toLowerCase();
// Dangerous tags: remove completely including content (XSS protection)
if (
['script', 'iframe', 'object', 'embed', 'style', 'link'].includes(
tagName
)
) {
child.parentNode.removeChild(child);
return;
}
// Other disallowed tags: remove tag but keep their content
if (!this.ALLOWED_TAGS.includes(tagName)) {
const parent = child.parentNode;
while (child.firstChild) {
parent.insertBefore(child.firstChild, child);
}
parent.removeChild(child);
return;
}
// Clean attributes
this.cleanAttributes(child);
// Recursively clean children
this.cleanNode(child);
}
});
},
/**
* Clean attributes of an element
* @param {Element} element - Element to clean
*/
cleanAttributes(element) {
const tagName = element.tagName.toLowerCase();
const allowedForTag = this.ALLOWED_ATTRIBUTES[tagName] || [];
const allowedGlobal = this.ALLOWED_ATTRIBUTES['*'] || [];
const allowedAttrs = [...allowedForTag, ...allowedGlobal];
// Remove disallowed attributes
Array.from(element.attributes).forEach((attr) => {
const attrName = attr.name.toLowerCase();
// Check if attribute is allowed
const isAllowed = allowedAttrs.some((allowed) => {
if (allowed === attrName) return true;
if (allowed.endsWith('*')) {
return attrName.startsWith(allowed.slice(0, -1));
}
return false;
});
if (!isAllowed) {
element.removeAttribute(attr.name);
}
});
// Special handling for links
if (tagName === 'a') {
const href = element.getAttribute('href');
if (href) {
// Ensure external links have proper security attributes
if (href.startsWith('http://') || href.startsWith('https://')) {
if (element.getAttribute('target') === '_blank') {
element.setAttribute('rel', 'noopener noreferrer');
}
}
// Remove javascript: and data: URLs
if (href.startsWith('javascript:') || href.startsWith('data:')) {
element.removeAttribute('href');
}
}
}
// Special handling for images
if (tagName === 'img') {
const src = element.getAttribute('src');
if (src && (src.startsWith('javascript:') || src.startsWith('data:'))) {
element.removeAttribute('src');
}
}
},
};
/**
* Creates a RichText component for rendering sanitized HTML content
* @param {Object} props - RichText properties
* @returns {Object} RichText component
*/
const createRichText = (props) => {
// Inject styles on first render
injectRichTextStyles(richTextStyles);
// Migrate legacy props according to standardization
const migrateLegacyProps = (originalProps) => {
const migrated = { ...originalProps };
// Map common legacy prop names
if (migrated.html && !migrated.content) {
console.warn('[RichText] html prop is deprecated, use content instead');
migrated.content = migrated.html;
delete migrated.html;
}
if (migrated.value && !migrated.content) {
migrated.content = migrated.value;
delete migrated.value;
}
return migrated;
};
// Apply prop migration
const migratedProps = migrateLegacyProps(props);
// Validate props
if (!migratedProps.content) {
throw new Error('RichText: content is required');
}
if (typeof migratedProps.content !== 'string') {
throw new Error('RichText: content must be a string');
}
if (
migratedProps.sanitize !== undefined &&
typeof migratedProps.sanitize !== 'boolean'
) {
throw new Error('RichText: sanitize must be a boolean');
}
if (
migratedProps.maxLength !== undefined &&
(!Number.isInteger(migratedProps.maxLength) || migratedProps.maxLength < 0)
) {
throw new Error('RichText: maxLength must be a positive integer');
}
// Component state
let state = { ...migratedProps };
let element = null;
let isDestroyed = false;
let wasTruncated = false; // Track truncation state for isTruncated()
/**
* Process links in the rendered content
* @param {HTMLElement} container - Container element
*/
const processLinks = (container) => {
const links = container.querySelectorAll('a');
links.forEach((link) => {
const href = link.getAttribute('href');
if (href) {
// Add classes for styling
link.classList.add('richtext__link');
// Handle external links
if (href.startsWith('http://') || href.startsWith('https://')) {
link.classList.add('richtext__link--external');
if (link.getAttribute('target') === '_blank') {
link.setAttribute('rel', 'noopener noreferrer');
}
}
// Handle email links
if (href.startsWith('mailto:')) {
link.classList.add('richtext__link--email');
}
// Handle phone links
if (href.startsWith('tel:')) {
link.classList.add('richtext__link--phone');
}
}
});
};
/**
* Renders the rich text element
* @returns {HTMLElement} RichText element
*/
const render = () => {
if (isDestroyed) {
console.warn('Attempted to render destroyed RichText component');
return null;
}
// Build class names
const classNames = [
'richtext',
state.variant ? `richtext--${state.variant}` : '',
state.size ? `richtext--${state.size}` : '',
state.className || '',
].filter(Boolean);
// Sanitize content if enabled (default: true)
const shouldSanitize = state.sanitize !== false;
let processedContent = state.content;
if (shouldSanitize) {
processedContent = SecurityHelpers.sanitizeHTML(processedContent);
}
// Apply maxLength if specified
wasTruncated = false; // Reset truncation state
if (state.maxLength && processedContent.length > state.maxLength) {
// Find a good place to truncate (avoid breaking HTML tags)
const tempDiv = document.createElement('div');
tempDiv.innerHTML = processedContent;
const textContent = tempDiv.textContent || tempDiv.innerText || '';
if (textContent.length > state.maxLength) {
const truncated = `${textContent.substring(0, state.maxLength - 3)}...`;
processedContent = SecurityHelpers.sanitizeHTML(truncated);
wasTruncated = true; // Mark as truncated
}
}
// Use componentFactory createElement utility
const newElement = createElement('div', {
classes: classNames,
attributes: {
id: state.id || null,
role: 'document',
'aria-label': state.ariaLabel || null,
},
events: {
click: state.onClick,
},
});
// Set innerHTML with processed content
newElement.innerHTML = processedContent;
// Post-process links if needed
if (state.processLinks !== false) {
processLinks(newElement);
}
return newElement;
};
// Initial render
element = render();
// Public API following Svarog component patterns
return {
/**
* Get the component element
* @returns {HTMLElement} Component element
*/
getElement() {
return element;
},
/**
* Update component with new props
* @param {Object} newProps - New properties
* @returns {Object} Component (for chaining)
*/
update(newProps) {
if (isDestroyed) {
console.warn('Attempted to update destroyed RichText component');
return this;
}
// Apply prop migration to new props
const migratedNewProps = migrateLegacyProps(newProps);
// Update state
Object.assign(state, migratedNewProps);
// Re-render (vanilla JS approach - always full re-render for simplicity)
const oldElement = element;
element = render();
// Replace in DOM if the old element was inserted
if (oldElement && oldElement.parentNode) {
oldElement.parentNode.replaceChild(element, oldElement);
}
return this;
},
/**
* Set rich text content
* @param {string} newContent - New HTML content
* @returns {Object} RichText component (for chaining)
*/
setContent(newContent) {
return this.update({ content: newContent });
},
/**
* Get the text content (without HTML tags)
* @returns {string} Plain text content
*/
getTextContent() {
if (!element) return '';
return element.textContent || element.innerText || '';
},
/**
* Get the HTML content
* @returns {string} HTML content
*/
getHTMLContent() {
if (!element) return '';
return element.innerHTML;
},
/**
* Check if content exceeds maximum length
* @returns {boolean} Whether content is truncated
*/
isTruncated() {
return wasTruncated;
},
/**
* Search for text within the content
* @param {string} searchTerm - Text to search for
* @returns {boolean} Whether the text was found
*/
contains(searchTerm) {
const textContent = this.getTextContent().toLowerCase();
return textContent.includes(searchTerm.toLowerCase());
},
/**
* Clean up resources - using componentFactory cleanup pattern
*/
destroy() {
if (isDestroyed) return;
isDestroyed = true;
// Use componentFactory cleanup utility
if (element) {
cleanupEventListeners(element);
// Remove from DOM if inserted
if (element.parentNode) {
element.parentNode.removeChild(element);
}
}
// Clear references
element = null;
state = null;
},
/**
* Theme change handler
*/
onThemeChange(newTheme, previousTheme) {
console.debug(
`RichText: theme changed from ${previousTheme} to ${newTheme}`
);
},
};
};
// Define required props for validation
createRichText.requiredProps = ['content'];
// Export as a factory function
export default createRichText;