Skip to content

Commit

Permalink
Merge pull request #89 from storyblok/bugfix/self-closing-tag-support
Browse files Browse the repository at this point in the history
fix: self closing tag support
  • Loading branch information
alvarosabu authored Sep 9, 2024
2 parents 022f60c + 48f89b9 commit 80458eb
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 83 deletions.
36 changes: 32 additions & 4 deletions src/richtext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,38 @@ describe('richtext', () => {
attrs: {
src: 'https://example.com/image.jpg',
alt: 'An image',
copyright: '© Storyblok',
source: 'Storyblok',
title: 'An image',
meta_data: {
alt: 'An image',
copyright: '© Storyblok',
source: 'Storyblok',
},
},
}
const html = render(image as Node<string>)
expect(html).toBe('<img src="https://example.com/image.jpg" alt="An image" key="img-1"></img>')
expect(html).toBe('<img src="https://example.com/image.jpg" alt="An image" title="An image" key="img-1" />')
})

it('should render self-closing tags', async () => {
const { render } = richTextResolver({})
const selfClosingBlockTypes = [
'HR', 'BR', 'IMAGE'
]
const tagMap = {
HR: 'hr',
BR: 'br',
IMAGE: 'img',
}
selfClosingBlockTypes.forEach((type, index) => {
const node = {
type: BlockTypes[type as keyof typeof BlockTypes],
}
const html = render(node as Node<string>)

expect(html).toBe(`<${tagMap[type]} key="${tagMap[type]}-${index + 1}" />`)
})
})

it('should render an emoji', async () => {
Expand All @@ -122,7 +150,7 @@ describe('richtext', () => {
},
}
const html = render(emoji as Node<string>)
expect(html).toBe('<span data-type="emoji" data-name="undefined" emoji="🚀" key="emoji-1"><img src="undefined" alt="undefined" style="width: 1.25em; height: 1.25em; vertical-align: text-top" draggable="false" loading="lazy"></img></span>')
expect(html).toBe('<span data-type="emoji" data-name="undefined" emoji="🚀" key="emoji-1"><img src="undefined" alt="undefined" style="width: 1.25em; height: 1.25em; vertical-align: text-top" draggable="false" loading="lazy" /></span>')
})

it('should render a code block', async () => {
Expand All @@ -146,7 +174,7 @@ describe('richtext', () => {
type: 'horizontal_rule',
}
const html = render(hr as Node<string>)
expect(html).toBe('<hr key="hr-1"></hr>')
expect(html).toBe('<hr key="hr-1" />')
})

it('should render a break', async () => {
Expand All @@ -155,7 +183,7 @@ describe('richtext', () => {
type: 'hard_break',
}
const html = render(br as Node<string>)
expect(html).toBe('<br key="br-1"></br>')
expect(html).toBe('<br key="br-1" />')
})

it('should render a quote' , async () => {
Expand Down
90 changes: 11 additions & 79 deletions src/richtext.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,7 @@
import { optimizeImage } from './images-optimization'
import { BlockTypes, LinkTypes, MarkTypes, TextTypes, StoryblokRichTextOptions } from './types'
import type { MarkNode, StoryblokRichTextNode, StoryblokRichTextNodeResolver, StoryblokRichTextNodeTypes, TextNode } from './types'


/**
* Converts an object of attributes to a string.
*
* @param {Record<string, string>} [attrs={}]
*
* @returns {string} The string representation of the attributes.
*
* @example
*
* ```typescript
* const attrs = {
* class: 'text-red',
* style: 'color: red',
* }
*
* const attrsString = attrsToString(attrs)
*
* console.log(attrsString) // 'class="text-red" style="color: red"'
*
* ```
*
*/
const attrsToString = (attrs: Record<string, string> = {}) => Object.keys(attrs)
.map(key => `${key}="${attrs[key]}"`)
.join(' ')

/**
* Converts an object of attributes to a CSS style string.
*
* @param {Record<string, string>} [attrs={}]
*
* @returns {string} The string representation of the CSS styles.
*
* @example
*
* ```typescript
* const attrs = {
* color: 'red',
* fontSize: '16px',
* }
*
* const styleString = attrsToStyle(attrs)
*
* console.log(styleString) // 'color: red; font-size: 16px'
*/
const attrsToStyle = (attrs: Record<string, string> = {}) => Object.keys(attrs)
.map(key => `${key}: ${attrs[key]}`)
.join('; ')

/**
* Escapes HTML entities in a string.
*
* @param {string} unsafeText
* @return {*} {string}
*
* @example
*
* ```typescript
* const unsafeText = '<script>alert("Hello")</script>'
*
* const safeText = escapeHtml(unsafeText)
*
* console.log(safeText) // '&lt;script&gt;alert("Hello")&lt;/script&gt;'
* ```
*/
function escapeHtml(unsafeText: string): string {
return unsafeText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
import { attrsToString, attrsToStyle, cleanObject, escapeHtml, SELF_CLOSING_TAGS } from './utils'

/**
* Default render function that creates an HTML string for a given tag, attributes, and children.
Expand All @@ -89,6 +15,10 @@ function escapeHtml(unsafeText: string): string {
function defaultRenderFn<T = string | null>(tag: string, attrs: Record<string, any> = {}, children: T): T {
const attrsString = attrsToString(attrs)
const tagString = attrsString ? `${tag} ${attrsString}` : tag

if(SELF_CLOSING_TAGS.includes(tag)) {
return `<${tagString} />` as unknown as T
}
return `<${tagString}>${Array.isArray(children) ? children.join('') : children || ''}</${tag}>` as unknown as T
}

Expand All @@ -114,7 +44,7 @@ export function richTextResolver<T>(options: StoryblokRichTextOptions<T> = {} )
const nodeResolver = (tag: string): StoryblokRichTextNodeResolver<T> => (node: StoryblokRichTextNode<T>): T => renderFn(tag, { ...node.attrs, key: `${tag}-${currentKey}` } || {}, node.children || null as any) as T

const imageResolver: StoryblokRichTextNodeResolver<T> = (node: StoryblokRichTextNode<T>) => {
const { src, alt, ...rest } = node.attrs || {};
const { src, alt, title, srcset, sizes } = node.attrs || {};
let finalSrc = src;
let finalAttrs = {};

Expand All @@ -125,13 +55,15 @@ export function richTextResolver<T>(options: StoryblokRichTextOptions<T> = {} )
}
const imgAttrs = {
src: finalSrc,
alt: alt || '',
alt,
title,
srcset,
sizes,
key: `img-${currentKey}`,
...rest,
...finalAttrs,
};

return renderFn('img', imgAttrs, '') as T;
return renderFn('img', cleanObject(imgAttrs), '') as T;
};
const headingResolver: StoryblokRichTextNodeResolver<T> = (node: StoryblokRichTextNode<T>): T => {
const { level, ...rest } = node.attrs || {}
Expand Down
105 changes: 105 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@


export const SELF_CLOSING_TAGS = [
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr'
]

/**
* Converts an object of attributes to a string.
*
* @param {Record<string, string>} [attrs={}]
*
* @returns {string} The string representation of the attributes.
*
* @example
*
* ```typescript
* const attrs = {
* class: 'text-red',
* style: 'color: red',
* }
*
* const attrsString = attrsToString(attrs)
*
* console.log(attrsString) // 'class="text-red" style="color: red"'
*
* ```
*
*/
export const attrsToString = (attrs: Record<string, string> = {}) => Object.keys(attrs)
.map(key => `${key}="${attrs[key]}"`)
.join(' ')

/**
* Converts an object of attributes to a CSS style string.
*
* @param {Record<string, string>} [attrs={}]
*
* @returns {string} The string representation of the CSS styles.
*
* @example
*
* ```typescript
* const attrs = {
* color: 'red',
* fontSize: '16px',
* }
*
* const styleString = attrsToStyle(attrs)
*
* console.log(styleString) // 'color: red; font-size: 16px'
* ```
*/
export const attrsToStyle = (attrs: Record<string, string> = {}) => Object.keys(attrs)
.map(key => `${key}: ${attrs[key]}`)
.join('; ')

/**
* Escapes HTML entities in a string.
*
* @param {string} unsafeText
* @return {*} {string}
*
* @example
*
* ```typescript
* const unsafeText = '<script>alert("Hello")</script>'
*
* const safeText = escapeHtml(unsafeText)
*
* console.log(safeText) // '&lt;script&gt;alert("Hello")&lt;/script&gt;'
* ```
*/
export function escapeHtml(unsafeText: string): string {
return unsafeText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

/**
* Removes undefined values from an object.
*
* @param {Record<string, any>} obj
* @return {*} {Record<string, any>}
*
* @example
*
* ```typescript
* const obj = {
* name: 'John',
* age: undefined,
* }
*
* const cleanedObj = cleanObject(obj)
*
* console.log(cleanedObj) // { name: 'John' }
* ```
*
*/
export const cleanObject = (obj: Record<string, any>) => {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined))
}

0 comments on commit 80458eb

Please sign in to comment.