import { component } from '../../components/component'
import { escapeHtmlEntities } from '../sanitize'
import { isRenderedBlock } from '../../components/typeguards'
import type { BlockDefinition, ResolvableString } from '../../components/types/structures.type'
/**
* HTML Block component.
*
* Use this to render raw HTML content within forms.
*
* When `tag` is set, content is wrapped in that element with `classes` and `attributes`
* applied directly. When `tag` is not set but `classes`/`attributes` are present, falls
* back to a wrapper `
`. Content can be a string or an array of rendered blocks
* (e.g. from a collection expression), which are concatenated into a single string.
*
* **WARNING: XSS Risk — Content is rendered as raw HTML without any sanitization.**
*
* Any dynamic data interpolated into the content (e.g. via `Format()`, `Data()`, `Item()`)
* will be rendered as-is. If that data comes from user input or external sources, it **must**
* be escaped using `Transformer.String.EscapeHtml()` to prevent injection attacks.
*
* @example Safe — static developer HTML:
* ```typescript
* HtmlBlock({
* content: '
Terms of Service
',
* })
* ```
*
* @example Safe — dynamic data escaped before interpolation:
* ```typescript
* HtmlBlock({
* content: Format(
* '
%1
',
* Data('goalTitle').pipe(Transformer.String.EscapeHtml()),
* ),
* })
* ```
*
* @example UNSAFE — dynamic data interpolated without escaping:
* ```typescript
* // DO NOT do this — vulnerable to XSS if goalTitle contains malicious HTML
* HtmlBlock({
* content: Format('
%1
', Data('goalTitle')),
* })
* ```
*/
export interface HtmlBlock extends BlockDefinition {
/**
* HTML tag to render content within. When set, `classes` and `attributes`
* are applied directly to this element instead of a wrapper `
`.
*/
tag?: string
/**
* Content to render. Accepts a string, a dynamic expression, or an array of child blocks.
* When `tag` is a void element (e.g. `hr`), content is ignored.
*
* **WARNING: Not sanitized.** Escape any untrusted data with `Transformer.String.EscapeHtml()`.
*/
content?: ResolvableString | BlockDefinition | BlockDefinition[]
/** Additional CSS classes to apply to the element (optional) */
classes?: ResolvableString
/** Custom HTML attributes for the element (optional) */
attributes?: Record
}
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'source',
'track',
'wbr',
])
const resolveContent = (content: unknown): string => {
if (Array.isArray(content)) {
return content.map(item => resolveContent(item)).join('')
}
if (isRenderedBlock(content)) {
return content.html
}
return (content as string) ?? ''
}
/**
* HTML Block component.
*
* Use this to render raw HTML content within forms.
*
* When `tag` is set, content is wrapped in that element with `classes` and `attributes`
* applied directly. When `tag` is not set but `classes`/`attributes` are present, falls
* back to a wrapper ``. Content can be a string or an array of rendered blocks
* (e.g. from a collection expression), which are concatenated into a single string.
*
* **WARNING: XSS Risk — Content is rendered as raw HTML without any sanitization.**
*
* Any dynamic data interpolated into the content (e.g. via `Format()`, `Data()`, `Item()`)
* will be rendered as-is. If that data comes from user input or external sources, it **must**
* be escaped using `Transformer.String.EscapeHtml()` to prevent injection attacks.
*
* @example Safe — static developer HTML:
* ```typescript
* HtmlBlock({
* content: '
Terms of Service
',
* })
* ```
*
* @example Safe — dynamic data escaped before interpolation:
* ```typescript
* HtmlBlock({
* content: Format(
* '
%1
',
* Data('goalTitle').pipe(Transformer.String.EscapeHtml()),
* ),
* })
* ```
*
* @example UNSAFE — dynamic data interpolated without escaping:
* ```typescript
* // DO NOT do this — vulnerable to XSS if goalTitle contains malicious HTML
* HtmlBlock({
* content: Format('
%1
', Data('goalTitle')),
* })
* ```
*/
export const HtmlBlock = component
('html', {
render: props => {
const hasAttrs = props.classes || props.attributes
if (!props.tag && !hasAttrs) {
return resolveContent(props.content)
}
const element = props.tag ?? 'div'
const classAttr = props.classes ? ` class="${escapeHtmlEntities(props.classes)}"` : ''
const customAttrs = props.attributes
? Object.entries(props.attributes)
.map(([key, value]) => ` ${escapeHtmlEntities(key)}="${escapeHtmlEntities(String(value))}"`)
.join('')
: ''
if (VOID_ELEMENTS.has(element)) {
return `<${element}${classAttr}${customAttrs}>`
}
return `<${element}${classAttr}${customAttrs}>${resolveContent(props.content)}${element}>`
},
})