import { component } from '../../components/component'
import { isRenderedBlock } from '../../components/typeguards'
import { escapeHtmlEntities } from '../sanitize'
import type { BlockDefinition, ResolvableString } from '../../components/types/structures.type'
/**
* TemplateWrapper component.
*
* Template wrapper allows wrapping child blocks in an HTML template.
* Slots in the template use the syntax `{{slot:slotName}}` and will be replaced
* with the rendered HTML of the corresponding blocks in the `slots` property.
*
* Values in the template use the syntax `{{valueName}}` and will be replaced
* with the corresponding string value from the `values` property.
*
* @example
* ```typescript
* TemplateWrapper({
* template: `
*
* {{title}}
* {{slot:content}}
* {{footer}}
*
* `,
* values: {
* title: 'Journey Configuration',
* footer: 'See the next page for step configuration.'
* },
* slots: {
* content: [
* HtmlBlock({ content: '
Explanation...
' }),
* GovUKCodeBlock({ code: '...' }),
* ]
* }
* })
* ```
*/
export interface TemplateWrapper extends BlockDefinition {
/**
* HTML template with slot markers ({{slot:name}}) and value markers ({{name}}).
*
* @example '{{slot:content}}
'
* @example '{{title}}
{{slot:body}}'
*/
template: ResolvableString
/**
* String values to inject into the template at {{name}} markers.
*
* **WARNING: Not sanitized.** Values are injected directly into the HTML template.
* Escape any untrusted data with `Transformer.String.EscapeHtml()`.
*
* @example { title: 'Section Title', footer: 'Footer text' }
*/
values?: Record
/**
* Named slots containing blocks to render at {{slot:name}} markers.
*
* @example { content: [HtmlBlock({ content: 'Hello
' })] }
*/
slots?: Record
/**
* HTML tag to render content within. When set, `classes` and `attributes`
* are applied directly to this element instead of a wrapper ``.
*
* @example 'section'
*/
tag?: string
/**
* Additional CSS classes to apply to the wrapper element (optional).
* Only applies when a wrapper element is rendered.
*
* @example 'govuk-!-margin-bottom-6'
*/
classes?: ResolvableString
/**
* Custom HTML attributes for the wrapper element (optional).
* Only applies when a wrapper element is rendered.
*
* @example { 'data-module': 'template-section' }
*/
attributes?: Record
}
/**
* Extracts a string value from a value that could be:
* - A plain string
* - A rendered block (with .html and .block properties)
* - An array of strings or rendered blocks
*/
const extractStringValue = (value: unknown): string => {
if (Array.isArray(value)) {
return value.map(v => extractStringValue(v)).join('')
}
if (isRenderedBlock(value)) {
return value.html
}
return (value as string) ?? ''
}
/**
* TemplateWrapper component.
*
* Template wrapper allows wrapping child blocks in an HTML template.
* Slots in the template use the syntax `{{slot:slotName}}` and will be replaced
* with the rendered HTML of the corresponding blocks in the `slots` property.
*
* Values in the template use the syntax `{{valueName}}` and will be replaced
* with the corresponding string value from the `values` property.
*
* @example
* ```typescript
* TemplateWrapper({
* template: `
*
* {{title}}
* {{slot:content}}
* {{footer}}
*
* `,
* values: {
* title: 'Journey Configuration',
* footer: 'See the next page for step configuration.'
* },
* slots: {
* content: [
* HtmlBlock({ content: 'Explanation...
' }),
* GovUKCodeBlock({ code: '...' }),
* ]
* }
* })
* ```
*/
export const TemplateWrapper = component('templateWrapper', {
render: props => {
let content = props.template
// Replace value markers: {{valueName}}
// Values are developer-controlled (not user input), so no escaping needed.
// User input flows through form fields and is escaped by Nunjucks at render time.
if (props.values) {
Object.entries(props.values).forEach(([key, value]) => {
const marker = `{{${key}}}`
const stringValue = extractStringValue(value)
content = content.split(marker).join(stringValue)
})
}
// Replace slot markers: {{slot:slotName}}
if (props.slots) {
Object.entries(props.slots).forEach(([slotName, renderedBlocks]) => {
const marker = `{{slot:${slotName}}}`
const slotHtml = renderedBlocks.map(b => b.html).join('')
content = content.split(marker).join(slotHtml)
})
}
// Clean up any unreplaced markers (slots/values that weren't provided)
content = content.replace(/\{\{slot:[^}]+}}/g, '')
content = content.replace(/\{\{[^}]+}}/g, '')
const hasWrapper = props.tag || props.classes || props.attributes
if (hasWrapper) {
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('')
: ''
return `<${element}${classAttr}${customAttrs}>${content}${element}>`
}
return content
},
})