├── .eslintrc.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.yml ├── LICENSE ├── README.md ├── jest.config.cjs ├── package-lock.json ├── package.json ├── src ├── constants │ └── common.ts ├── models │ ├── rules.ts │ └── task.ts ├── sanitize.spec.ts ├── sanitize.ts └── utils │ ├── common.spec.ts │ ├── common.ts │ ├── completeTask.spec.ts │ ├── completeTask.ts │ ├── convert.spec.ts │ ├── convert.ts │ ├── findRule.spec.ts │ ├── findRule.ts │ ├── unpack.spec.ts │ ├── unpack.ts │ ├── validateAttributes.spec.ts │ ├── validateAttributes.ts │ ├── validateClasses.spec.ts │ ├── validateClasses.ts │ ├── validateCollection.ts │ ├── validateStyles.spec.ts │ └── validateStyles.ts ├── testing-utils ├── handleHTML.ts └── index.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: standard-with-typescript 5 | parserOptions: 6 | ecmaVersion: latest 7 | sourceType: module 8 | ignorePatterns: 9 | - dist 10 | rules: 11 | '@typescript-eslint/indent': 12 | off 13 | '@typescript-eslint/member-delimiter-style': 14 | - error 15 | - multiline: 16 | delimiter: none 17 | singleline: 18 | delimiter: semi 19 | requireLast: false 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | npm test 3 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | prettier-config-standard 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Weacom.ru 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTML Markup Sanitizer 2 | 3 | A lightweight and dependency-free utility for sanitizing HTML markup according to custom rules. 4 | Key features include: 5 | 6 | - No external dependencies required; 7 | - Simple yet powerful rule configuration. 8 | 9 | ## Installation 10 | 11 | ```shell 12 | npm i html-formatting 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import { sanitize } from 'html-formating' 19 | 20 | sanitize(node, rules) 21 | ``` 22 | 23 | - `node`: A DOM element whose children will be formatted according to the specified rules (the container itself will not be affected); 24 | - `rules`: An object containing the formatting rules ([Rules](#rules)). 25 | 26 | ### Configuration 27 | 28 | #### Rules 29 | 30 | **Type definition**: [Rules](src/models/rules.ts) 31 | 32 | There are two types of rules: global rules for text nodes, 33 | and a set of valid HTML elements along with corresponding rules for them 34 | 35 | | Param | Type | Description | 36 | | :---------------- | :--------------------: | :----------------------------------------------------------------- | 37 | | **text** | `TextRule` | Global rules for text nodes | 38 | | **validElements** | `Record` | Set of valid HTML elements along with corresponding rules for them | 39 | 40 | #### TextRule 41 | 42 | Global rules for text nodes 43 | 44 | | Param | Type | Description | 45 | | :-------------- | :------------------------: | :----------------------------------------------------------------------------------------------------------- | 46 | | **noNBSP** | `boolean` | A predefined handler that removes all non-breaking spaces from the text within the container being processed | 47 | | **processText** | `(text: string) => string` | A method for defining a custom text handler | 48 | 49 | _An example_ of processing text by removing all non-breaking spaces and changing case to uppercase 50 | 51 | ```js 52 | sanitize(node, { 53 | text: { noNBSP: true, processText: (text) => text.toUpperCase() } 54 | }) 55 | //

Hello,[NBSP]World!

->

HELLO, WORLD!

56 | ``` 57 | 58 | #### Valid Elements 59 | 60 | `validElements` is an object (a record) where each key is a set of valid HTML tags separated by comma, 61 | and the value is a manipulation configuration ([Rule](#rule)) appropriate for that set. 62 | The specified tags can be retained "as is" without any additional processing by simply assigning 63 | an empty object `{}` as their value. 64 | From this, it follows that any HTML tags not mentioned in any rules object key will be removed. 65 | 66 | _For instance_, to preserve only headings and paragraphs in the final HTML markup, 67 | the following configuration can be specified: 68 | 69 | ```js 70 | sanitize(node, { 71 | validElements: { 'h1,h2,h3,h4,h5,h6,p': {} } 72 | }) 73 | //

Title

Caption

Description

74 | // ->

Title

Caption

Description

75 | ``` 76 | 77 | #### Rule 78 | 79 | Configuration of additional processing of html elements 80 | 81 | | Param | Type | Description | 82 | | :------------------ | :------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 83 | | **convertTo** | `string` | Specifies the HTML element into which this group should be converted | 84 | | **validAttributes** | `string` | A comma-separated list of allowed attributes.
All attributes are valid if the parameter is not specified.
It is possible to use a mask to allow a group of attributes, for example `data-*` | 85 | | **validStyles** | `string` | A comma-separated list of allowed inline styles.
All inline styles are valid if the parameter is not specified.
It is possible to use a mask to allow a group of styles, for example `font-*`.
This parameter **has no effect** if `validAttributes` is specified but does not include the `style` attribute | 86 | | **validClasses** | `string` | A comma-separated list of allowed css classes.
All css classes are valid if the parameter is not specified.
It is possible to use a mask to allow a group of classes, for example `indent-*`.
This parameter **has no effect** if `validAttributes` is specified but does not include the `class` attribute | 87 | | **noEmpty** | `boolean` | Indicates whether to remove empty elements | 88 | | **process** | `(element: HTMLElement) => void` | A method for defining a custom element handler | 89 | | **validChildren** | `Record` | Overriding rules for nested elements | 90 | 91 | ### Cases 92 | 93 | #### Convert h1 to h2 and remove all line breaks from headings 94 | 95 | ```js 96 | const headerRules = { 97 | br: { 98 | process: (node) => { 99 | const space = document.createTextNode(' ') 100 | node.parentNode.replaceChild(space, node) 101 | } 102 | } 103 | } 104 | 105 | sanitize(node, { 106 | validElements: { 107 | h1: { convertTo: 'h2', validChildren: headerRules }, 108 | 'h2,h3,h4,h5,h6': { validChildren: headerRules } 109 | } 110 | }) 111 | //

Breaking
News

->

Breaking News

112 | ``` 113 | 114 | #### Add target='\_blank' to external links 115 | 116 | ```js 117 | sanitize(node, { 118 | validElements: { 119 | a: { 120 | process: (link) => { 121 | if (!link.href.startsWith(window.location.origin)) { 122 | link.target = '_blank' 123 | } 124 | } 125 | } 126 | } 127 | }) 128 | //
Search
->
Search
129 | ``` 130 | 131 | ## License 132 | 133 | MIT. 134 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | moduleNameMapper: { 6 | '~/(.*)': '/src/$1', 7 | 'testing-utils': '/testing-utils' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-formatting", 3 | "version": "2.0.0", 4 | "description": "HTML formatting in accordance with the given rules", 5 | "main": "dist/sanitize.js", 6 | "files": [ 7 | "dist", 8 | "LICENSE", 9 | "README.md" 10 | ], 11 | "devDependencies": { 12 | "@types/jest": "^29.5.12", 13 | "@typescript-eslint/eslint-plugin": "^6.21.0", 14 | "eslint": "^8.56.0", 15 | "eslint-config-standard-with-typescript": "^43.0.1", 16 | "eslint-plugin-import": "^2.29.1", 17 | "eslint-plugin-n": "^16.6.2", 18 | "eslint-plugin-promise": "^6.1.1", 19 | "husky": "^9.0.11", 20 | "jest": "^29.7.0", 21 | "jest-environment-jsdom": "^29.7.0", 22 | "lint-staged": "^15.2.2", 23 | "prettier": "^3.2.5", 24 | "prettier-config-standard": "^7.0.0", 25 | "rimraf": "^5.0.5", 26 | "ts-jest": "^29.1.2", 27 | "tsc-alias": "^1.8.8", 28 | "typescript": "^5.3.3" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/WEACOMRU/sanitize-html.git" 33 | }, 34 | "keywords": [ 35 | "html", 36 | "wysiwyg", 37 | "cleanup", 38 | "sanitize" 39 | ], 40 | "author": "Konstantin Basharkevich ", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/WEACOMRU/sanitize-html/issues" 44 | }, 45 | "homepage": "https://github.com/WEACOMRU/sanitize-html#readme", 46 | "scripts": { 47 | "lib:build": "npm run lib:clean && npm run lib:compile", 48 | "lib:clean": "rimraf dist", 49 | "lib:compile": "tsc --project tsconfig.build.json && tsc-alias -p tsconfig.build.json", 50 | "test": "jest", 51 | "test:watch": "jest --watch", 52 | "lint": "eslint . --ext .ts", 53 | "lint:fix": "npm run lint -- --fix", 54 | "prepare": "husky", 55 | "prepublishOnly": "npm run lint && npm test && npm run lib:build" 56 | }, 57 | "lint-staged": { 58 | "*.ts": [ 59 | "eslint" 60 | ], 61 | "*.{ts,tsx,json,css,md}": [ 62 | "prettier --write" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/constants/common.ts: -------------------------------------------------------------------------------- 1 | export const EMPTY_TEXT_NODE_REGEXP = /^\s*$/ 2 | -------------------------------------------------------------------------------- /src/models/rules.ts: -------------------------------------------------------------------------------- 1 | export interface Rule { 2 | convertTo?: string 3 | validAttributes?: string 4 | validStyles?: string 5 | validClasses?: string 6 | noEmpty?: boolean 7 | process?: (element: HTMLElement) => void 8 | validChildren?: Record 9 | } 10 | 11 | export interface TextRule { 12 | noNBSP?: boolean 13 | processText?: (text: string) => string 14 | } 15 | 16 | export interface Rules { 17 | text?: TextRule 18 | validElements: Record 19 | } 20 | -------------------------------------------------------------------------------- /src/models/task.ts: -------------------------------------------------------------------------------- 1 | export type Task = 2 | | RemoveTask 3 | | ConvertTask 4 | | ProcessTask 5 | | UnpackTask 6 | 7 | export interface RemoveTask { 8 | type: 'remove' 9 | element: T 10 | } 11 | 12 | export interface ConvertTask { 13 | type: 'convert' 14 | element: T 15 | convertTo: string 16 | } 17 | 18 | export interface ProcessTask { 19 | type: 'process' 20 | element: T 21 | process: (element: T) => void 22 | } 23 | 24 | export interface UnpackTask { 25 | type: 'unpack' 26 | element: T 27 | } 28 | -------------------------------------------------------------------------------- /src/sanitize.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { sanitize } from '~/sanitize' 3 | 4 | const example = 5 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 6 | 7 | describe('sanitize', () => { 8 | it('should only allow the specified elements', () => { 9 | const result = handleHTML(example, (container) => { 10 | sanitize(container, { validElements: { 'h1,div': {} } }) 11 | }) 12 | expect(result).toBe( 13 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 14 | ) 15 | }) 16 | 17 | it('should convert elements according to the given rules', () => { 18 | const result = handleHTML(example, (container) => { 19 | sanitize(container, { 20 | validElements: { h1: {}, div: { convertTo: 'p' } } 21 | }) 22 | }) 23 | expect(result).toBe( 24 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML

' 25 | ) 26 | }) 27 | 28 | it('should remove empty elements according to the given rules', () => { 29 | const result = handleHTML(example, (container) => { 30 | sanitize(container, { validElements: { 'h1,div': { noEmpty: true } } }) 31 | }) 32 | expect(result).toBe( 33 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 34 | ) 35 | }) 36 | 37 | it('should only allow the specified classes', () => { 38 | const result = handleHTML(example, (container) => { 39 | sanitize(container, { 40 | validElements: { 41 | h1: { validClasses: 'title' }, 42 | div: { validClasses: 'caption' } 43 | } 44 | }) 45 | }) 46 | expect(result).toBe( 47 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 48 | ) 49 | }) 50 | 51 | it('should only allow the specified styles', () => { 52 | const result = handleHTML(example, (container) => { 53 | sanitize(container, { 54 | validElements: { 55 | 'h1,div': {}, 56 | span: { validStyles: 'font-*' } 57 | } 58 | }) 59 | }) 60 | expect(result).toBe( 61 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 62 | ) 63 | }) 64 | 65 | it('should only allow the specified attributes', () => { 66 | const result = handleHTML(example, (container) => { 67 | sanitize(container, { 68 | validElements: { 'h1,div,span': { validAttributes: 'data-*,class' } } 69 | }) 70 | }) 71 | expect(result).toBe( 72 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 73 | ) 74 | }) 75 | 76 | it('should not take into account validStyles or validClasses properties if the corresponding attributes are not allowed', () => { 77 | const result = handleHTML(example, (container) => { 78 | sanitize(container, { 79 | validElements: { 80 | 'h1,div,span': { 81 | validAttributes: '', 82 | validClasses: 'title,caption', 83 | validStyles: 'font-*' 84 | } 85 | } 86 | }) 87 | }) 88 | expect(result).toBe( 89 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 90 | ) 91 | }) 92 | 93 | it('should apply nested rules to elements if specified', () => { 94 | const result = handleHTML(example, (container) => { 95 | sanitize(container, { 96 | validElements: { h1: {}, div: { validChildren: { span: {} } } } 97 | }) 98 | }) 99 | expect(result).toBe( 100 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 101 | ) 102 | }) 103 | 104 | it('should format text according to the given rules', () => { 105 | const result = handleHTML(example, (container) => { 106 | sanitize(container, { 107 | text: { noNBSP: true, processText: (text) => text.toUpperCase() }, 108 | validElements: { 'h1,div': {} } 109 | }) 110 | }) 111 | expect(result).toBe( 112 | '

HTML-FORMATTING

THIS IS A SIMPLE TOOL FOR FORMATTING (SANITIZING) A PEACE OF HTML
' 113 | ) 114 | }) 115 | 116 | it('should format text even in case of nesting rules', () => { 117 | const result = handleHTML(example, (container) => { 118 | sanitize(container, { 119 | text: { noNBSP: true, processText: (text) => text.toUpperCase() }, 120 | validElements: { 121 | h1: {}, 122 | div: { validChildren: { span: { validAttributes: '' } } } 123 | } 124 | }) 125 | }) 126 | expect(result).toBe( 127 | '

HTML-FORMATTING

THIS IS A SIMPLE TOOL FOR FORMATTING (SANITIZING) A PEACE OF HTML
' 128 | ) 129 | }) 130 | 131 | it('should process elements with a given method', () => { 132 | const result = handleHTML(example, (container) => { 133 | sanitize(container, { 134 | validElements: { 135 | h1: {}, 136 | div: { 137 | process: (element) => { 138 | element.classList.add('page-section') 139 | } 140 | } 141 | } 142 | }) 143 | }) 144 | expect(result).toBe( 145 | '

HTML-formatting

This is a simple tool for formatting (sanitizing) a peace of HTML
' 146 | ) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /src/sanitize.ts: -------------------------------------------------------------------------------- 1 | import { type Rules, type TextRule } from '~/models/rules' 2 | import { isElement, isEmpty, isTextNode } from '~/utils/common' 3 | import type { Task } from '~/models/task' 4 | import { completeTask } from '~/utils/completeTask' 5 | import { findRule } from '~/utils/findRule' 6 | import { validateAttributes } from '~/utils/validateAttributes' 7 | import { validateStyles } from '~/utils/validateStyles' 8 | import { validateClasses } from '~/utils/validateClasses' 9 | 10 | export const sanitize = (node: Node, rules: Rules): void => { 11 | const tasks: Array> = [] 12 | 13 | node.childNodes.forEach((node) => { 14 | if (isElement(node)) { 15 | sanitizeElement(node, rules, tasks) 16 | } else if (isTextNode(node) && rules.text != null) { 17 | sanitizeText(node, rules.text) 18 | } 19 | }) 20 | 21 | tasks.forEach(completeTask) 22 | } 23 | 24 | const sanitizeElement = ( 25 | element: T, 26 | rules: Rules, 27 | tasks: Array> 28 | ): void => { 29 | const rule = findRule(element.tagName, rules.validElements) 30 | sanitize( 31 | element, 32 | rule?.validChildren != null 33 | ? { text: rules.text, validElements: rule?.validChildren } 34 | : rules 35 | ) 36 | 37 | if (rule == null) { 38 | tasks.push({ type: 'unpack', element }, { type: 'remove', element }) 39 | } else { 40 | if (rule.noEmpty === true && isEmpty(element)) { 41 | tasks.push({ type: 'remove', element }) 42 | } else { 43 | validateAttributes(element, rule.validAttributes) 44 | validateStyles(element, rule.validStyles) 45 | validateClasses(element, rule.validClasses) 46 | 47 | if (rule.convertTo != null) { 48 | tasks.push({ 49 | type: 'convert', 50 | element, 51 | convertTo: rule.convertTo 52 | }) 53 | } 54 | 55 | if (typeof rule.process === 'function') { 56 | tasks.push({ 57 | type: 'process', 58 | element, 59 | process: rule.process 60 | }) 61 | } 62 | } 63 | } 64 | } 65 | 66 | const sanitizeText = (textNode: Text, rule: TextRule): void => { 67 | if (textNode.nodeValue == null) { 68 | return 69 | } 70 | if (rule.noNBSP === true) { 71 | textNode.nodeValue = textNode.nodeValue.replace(/\xa0/g, ' ') 72 | } 73 | if (rule.processText != null) { 74 | textNode.nodeValue = rule.processText(textNode.nodeValue) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/common.spec.ts: -------------------------------------------------------------------------------- 1 | import { isElement, isEmpty, isTextNode } from '~/utils/common' 2 | import { handleHTML } from 'testing-utils' 3 | 4 | describe('isElement', () => { 5 | it('should detect the node which is the element', () => { 6 | const element = document.createElement('div') 7 | expect(isElement(element)).toBeTruthy() 8 | 9 | const textNode = document.createTextNode('content') 10 | expect(isElement(textNode)).toBeFalsy() 11 | 12 | const attr = document.createAttribute('title') 13 | expect(isElement(attr)).toBeFalsy() 14 | }) 15 | }) 16 | 17 | describe('isTextNode', () => { 18 | it('should detect the text node', () => { 19 | const element = document.createElement('div') 20 | expect(isTextNode(element)).toBeFalsy() 21 | 22 | const textNode = document.createTextNode('content') 23 | expect(isTextNode(textNode)).toBeTruthy() 24 | 25 | const attr = document.createAttribute('title') 26 | expect(isTextNode(attr)).toBeFalsy() 27 | }) 28 | }) 29 | 30 | describe('isEmpty', () => { 31 | it('should detect whether the node is empty or not', () => { 32 | const result1 = handleHTML('


', isEmpty, { 33 | returnValue: 'result' 34 | }) 35 | expect(result1).toBeTruthy() 36 | 37 | const result2 = handleHTML( 38 | '

link

', 39 | isEmpty, 40 | { 41 | returnValue: 'result' 42 | } 43 | ) 44 | expect(result2).toBeFalsy() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY_TEXT_NODE_REGEXP } from '~/constants/common' 2 | 3 | export const isElement = (node: Node): node is HTMLElement => 4 | node.nodeType === Node.ELEMENT_NODE 5 | 6 | export const isTextNode = (node: Node): node is Text => 7 | node.nodeType === Node.TEXT_NODE 8 | 9 | export const isEmpty = (node: Node): boolean => 10 | Array.from(node.childNodes).every((childNode) => { 11 | if (isElement(childNode)) { 12 | return isEmpty(childNode) 13 | } else if (isTextNode(childNode)) { 14 | return ( 15 | childNode.nodeValue == null || 16 | EMPTY_TEXT_NODE_REGEXP.test(childNode.nodeValue) 17 | ) 18 | } 19 | return false 20 | }) 21 | -------------------------------------------------------------------------------- /src/utils/completeTask.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { completeTask } from '~/utils/completeTask' 3 | 4 | describe('completeTask', () => { 5 | it('should complete "remove" task', () => { 6 | const result = handleHTML( 7 | "It's going to be deleted", 8 | (container) => { 9 | completeTask({ 10 | type: 'remove', 11 | element: container.children[0] as HTMLElement 12 | }) 13 | } 14 | ) 15 | expect(result).toBe('') 16 | }) 17 | 18 | it('should complete "convert" task', () => { 19 | const result = handleHTML('

Title

', (container) => { 20 | completeTask({ 21 | type: 'convert', 22 | element: container.children[0] as HTMLElement, 23 | convertTo: 'h2' 24 | }) 25 | }) 26 | expect(result).toBe('

Title

') 27 | }) 28 | 29 | it('should complete "process" task', () => { 30 | const result = handleHTML( 31 | 'qodunpob', 32 | (container) => { 33 | completeTask({ 34 | type: 'process', 35 | element: container.children[0] as HTMLElement, 36 | process: (a: Element) => { 37 | a.setAttribute('target', '_blank') 38 | } 39 | }) 40 | } 41 | ) 42 | expect(result).toBe( 43 | 'qodunpob' 44 | ) 45 | }) 46 | 47 | it('should complete "unpack" task', () => { 48 | const result = handleHTML( 49 | '

First sentence

Second sentence

', 50 | (container) => { 51 | completeTask({ 52 | type: 'unpack', 53 | element: container.children[0] as HTMLElement 54 | }) 55 | } 56 | ) 57 | expect(result).toBe( 58 | '

First sentence

Second sentence

' 59 | ) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/utils/completeTask.ts: -------------------------------------------------------------------------------- 1 | import { type Task } from '~/models/task' 2 | import { convert } from '~/utils/convert' 3 | import { unpack } from '~/utils/unpack' 4 | 5 | export const completeTask = (task: Task): void => { 6 | switch (task.type) { 7 | case 'remove': 8 | task.element.parentNode?.removeChild(task.element) 9 | return 10 | case 'convert': 11 | convert(task.element, task.convertTo) 12 | return 13 | case 'process': 14 | task.process(task.element) 15 | return 16 | case 'unpack': 17 | unpack(task.element) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/convert.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { convert } from '~/utils/convert' 3 | 4 | describe('convert', () => { 5 | it('should convert the given element to the target one', () => { 6 | const result = handleHTML('important', (container) => { 7 | convert(container.children[0], 'i') 8 | }) 9 | 10 | expect(result).toBe('important') 11 | }) 12 | 13 | it('should move all children of the given element to the target one', () => { 14 | const result = handleHTML( 15 | '

ToDo

  • write test cases
  • implement solution
', 16 | (container) => { 17 | convert(container.children[0], 'section') 18 | } 19 | ) 20 | 21 | expect(result).toBe( 22 | '

ToDo

  • write test cases
  • implement solution
' 23 | ) 24 | }) 25 | 26 | it('should copy styles and classes from the given element to the target one', () => { 27 | const result = handleHTML( 28 | 'important', 29 | (container) => { 30 | convert(container.children[0], 'i') 31 | } 32 | ) 33 | 34 | expect(result).toBe( 35 | 'important' 36 | ) 37 | }) 38 | 39 | it('should copy attributes from the given element to the target one', () => { 40 | const result = handleHTML( 41 | 'First Name', 42 | (container) => { 43 | convert(container.children[0], 'label') 44 | } 45 | ) 46 | 47 | expect(result).toBe( 48 | '' 49 | ) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/utils/convert.ts: -------------------------------------------------------------------------------- 1 | export const convert = (element: Element, targetTagName: string): void => { 2 | const parentElement = element.parentElement 3 | const targetElement = document.createElement(targetTagName) 4 | 5 | for (const attribute of element.attributes) { 6 | targetElement.setAttribute(attribute.name, attribute.value) 7 | } 8 | 9 | while (element.childNodes.length > 0) { 10 | targetElement.appendChild(element.childNodes[0]) 11 | } 12 | 13 | parentElement?.replaceChild(targetElement, element) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/findRule.spec.ts: -------------------------------------------------------------------------------- 1 | import { findRule } from '~/utils/findRule' 2 | 3 | describe('findRule', () => { 4 | it('should return undefined if there is no matching rule', () => { 5 | const result = findRule('div', { h1: {}, p: {}, 'b,i,a': {} }) 6 | expect(result).toBeUndefined() 7 | }) 8 | 9 | it('should return the rule no matter at what position the target tag is specified', () => { 10 | const rule1 = findRule('div', { div: {} }) 11 | expect(rule1).not.toBeUndefined() 12 | 13 | const rule2 = findRule('div', { 'div,h1': {} }) 14 | expect(rule2).not.toBeUndefined() 15 | 16 | const rule3 = findRule('div', { 'p,div': {} }) 17 | expect(rule3).not.toBeUndefined() 18 | 19 | const rule4 = findRule('div', { 'p,div,span': {} }) 20 | expect(rule4).not.toBeUndefined() 21 | 22 | const rule5 = findRule('div', { 'p,,,,,div,': {} }) 23 | expect(rule5).not.toBeUndefined() 24 | }) 25 | 26 | it('should return only the first matching rule', () => { 27 | const result = findRule('div', { 28 | div: { convertTo: 'section' }, 29 | p: {}, 30 | 'b,i,a,div': { noEmpty: true } 31 | }) 32 | expect(result).toMatchObject({ convertTo: 'section' }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/utils/findRule.ts: -------------------------------------------------------------------------------- 1 | import { type Rule } from '~/models/rules' 2 | 3 | export const findRule = ( 4 | tagName: string, 5 | validElements: Record 6 | ): Rule | undefined => { 7 | const re = new RegExp(`(^|,)${tagName.toLowerCase()}(,|$)`) 8 | 9 | for (const ruleKey of Object.keys(validElements)) { 10 | if (re.test(ruleKey)) { 11 | return validElements[ruleKey] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/unpack.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { unpack } from '~/utils/unpack' 3 | 4 | describe('unpack', () => { 5 | it('should move all child elements of the node to a higher level', () => { 6 | const result = handleHTML( 7 | 'It\'s me, qodunpob', 8 | (container) => { 9 | unpack(container.children[0]) 10 | } 11 | ) 12 | 13 | expect(result).toBe( 14 | 'It\'s me, qodunpob' 15 | ) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utils/unpack.ts: -------------------------------------------------------------------------------- 1 | export const unpack = (element: Element): void => { 2 | const parentElement = element.parentElement 3 | if (parentElement == null) { 4 | return 5 | } 6 | 7 | while (element.childNodes.length > 0) { 8 | parentElement.insertBefore(element.childNodes[0], element) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/validateAttributes.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { validateAttributes } from '~/utils/validateAttributes' 3 | 4 | describe('validateAttributes', () => { 5 | it('should skip elements without attributes', () => { 6 | const result = handleHTML('
content
', (container) => { 7 | validateAttributes(container.children[0] as HTMLElement, '') 8 | }) 9 | 10 | expect(result).toBe('
content
') 11 | }) 12 | 13 | it('should allow all attributes if the rule is not specified', () => { 14 | const result = handleHTML( 15 | '
Big
', 16 | (container) => { 17 | validateAttributes(container.children[0] as HTMLElement) 18 | } 19 | ) 20 | 21 | expect(result).toBe( 22 | '
Big
' 23 | ) 24 | }) 25 | 26 | it('should only allow those attributes that are specified in the rule', () => { 27 | const result = handleHTML( 28 | '
Big
', 29 | (container) => { 30 | validateAttributes(container.children[0] as HTMLElement, 'id,class') 31 | } 32 | ) 33 | 34 | expect(result).toBe('
Big
') 35 | }) 36 | 37 | it('should allow those attributes that match the mask', () => { 38 | const result = handleHTML( 39 | '
Big
', 40 | (container) => { 41 | validateAttributes(container.children[0] as HTMLElement, 'data-*') 42 | } 43 | ) 44 | 45 | expect(result).toBe('
Big
') 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/utils/validateAttributes.ts: -------------------------------------------------------------------------------- 1 | import { validateCollection } from '~/utils/validateCollection' 2 | 3 | export const validateAttributes = ( 4 | element: HTMLElement, 5 | validAttributes?: string 6 | ): void => { 7 | if (validAttributes == null || element.attributes.length === 0) { 8 | return 9 | } 10 | 11 | validateCollection({ 12 | collection: element.attributes, 13 | validVariants: validAttributes, 14 | toStringItem: (item) => item.name, 15 | onInvalid: (item) => { 16 | element.removeAttribute(item.name) 17 | } 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/validateClasses.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { validateClasses } from '~/utils/validateClasses' 3 | 4 | describe('validateClasses', () => { 5 | it('should skip elements without classes', () => { 6 | const result = handleHTML('
content
', (container) => { 7 | validateClasses(container.children[0] as HTMLElement, '') 8 | }) 9 | 10 | expect(result).toBe('
content
') 11 | }) 12 | 13 | it('should allow all classes if the rule is not specified', () => { 14 | const result = handleHTML( 15 | '
Big
', 16 | (container) => { 17 | validateClasses(container.children[0] as HTMLElement) 18 | } 19 | ) 20 | 21 | expect(result).toBe('
Big
') 22 | }) 23 | 24 | it('should only allow those classes that are specified in the rule', () => { 25 | const result = handleHTML( 26 | '
Big
', 27 | (container) => { 28 | validateClasses(container.children[0] as HTMLElement, 'lead') 29 | } 30 | ) 31 | 32 | expect(result).toBe('
Big
') 33 | }) 34 | 35 | it('should allow those classes that match the mask', () => { 36 | const result = handleHTML( 37 | '
Big
', 38 | (container) => { 39 | validateClasses(container.children[0] as HTMLElement, 'text-*') 40 | } 41 | ) 42 | 43 | expect(result).toBe('
Big
') 44 | }) 45 | 46 | it('should remove the class attribute if it becomes empty after validation', () => { 47 | const result = handleHTML( 48 | '
Big
', 49 | (container) => { 50 | validateClasses(container.children[0] as HTMLElement, '') 51 | } 52 | ) 53 | 54 | expect(result).toBe('
Big
') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/utils/validateClasses.ts: -------------------------------------------------------------------------------- 1 | import { validateCollection } from '~/utils/validateCollection' 2 | 3 | export const validateClasses = ( 4 | element: HTMLElement, 5 | validClasses?: string 6 | ): void => { 7 | if (validClasses == null || element.classList.length === 0) { 8 | return 9 | } 10 | 11 | validateCollection({ 12 | collection: element.classList, 13 | validVariants: validClasses, 14 | onInvalid: (item) => { 15 | element.classList.remove(item) 16 | } 17 | }) 18 | 19 | if (element.classList.length === 0) { 20 | element.removeAttribute('class') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/validateCollection.ts: -------------------------------------------------------------------------------- 1 | export interface Collection { 2 | readonly length: number 3 | item: (index: number) => T | null 4 | } 5 | 6 | export interface ValidateCollectionProps { 7 | collection: Collection 8 | validVariants: string 9 | toStringItem?: (item: T) => string 10 | onInvalid: (item: T) => void 11 | } 12 | 13 | export const validateCollection = ({ 14 | collection, 15 | validVariants, 16 | toStringItem = (item) => String(item), 17 | onInvalid 18 | }: ValidateCollectionProps): void => { 19 | const validVariantsArray = validVariants.split(',').map(makeValidator) 20 | 21 | for (let i = collection.length - 1; i >= 0; i--) { 22 | const item = collection.item(i) 23 | 24 | if (item == null) { 25 | continue 26 | } 27 | if (!validVariantsArray.some(validateItem(toStringItem(item)))) { 28 | onInvalid(item) 29 | } 30 | } 31 | } 32 | 33 | const makeValidator = (variant: string): string | RegExp => 34 | variant.includes('*') ? new RegExp(variant.replace('*', '.')) : variant 35 | 36 | const validateItem = (item: string) => (validator: string | RegExp) => 37 | validator instanceof RegExp ? validator.test(item) : validator === item 38 | -------------------------------------------------------------------------------- /src/utils/validateStyles.spec.ts: -------------------------------------------------------------------------------- 1 | import { handleHTML } from 'testing-utils' 2 | import { validateStyles } from '~/utils/validateStyles' 3 | 4 | describe('validateStyles', () => { 5 | it('should skip elements without styles', () => { 6 | const result = handleHTML('
content
', (container) => { 7 | validateStyles(container.children[0] as HTMLElement, '') 8 | }) 9 | 10 | expect(result).toBe('
content
') 11 | }) 12 | 13 | it('should allow all styles if the rule is not specified', () => { 14 | const result = handleHTML( 15 | '
Big
', 16 | (container) => { 17 | validateStyles(container.children[0] as HTMLElement) 18 | } 19 | ) 20 | 21 | expect(result).toBe( 22 | '
Big
' 23 | ) 24 | }) 25 | 26 | it('should only allow those styles that are specified in the rule', () => { 27 | const result = handleHTML( 28 | '
Big
', 29 | (container) => { 30 | validateStyles( 31 | container.children[0] as HTMLElement, 32 | 'text-transform,width' 33 | ) 34 | } 35 | ) 36 | 37 | expect(result).toBe('
Big
') 38 | }) 39 | 40 | it('should allow those styles that match the mask', () => { 41 | const result = handleHTML( 42 | '
Big
', 43 | (container) => { 44 | validateStyles(container.children[0] as HTMLElement, 'font-*') 45 | } 46 | ) 47 | 48 | expect(result).toBe( 49 | '
Big
' 50 | ) 51 | }) 52 | 53 | it('should remove the style attribute if it becomes empty after validation', () => { 54 | const result = handleHTML( 55 | '
Big
', 56 | (container) => { 57 | validateStyles(container.children[0] as HTMLElement, '') 58 | } 59 | ) 60 | 61 | expect(result).toBe('
Big
') 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/utils/validateStyles.ts: -------------------------------------------------------------------------------- 1 | import { validateCollection } from '~/utils/validateCollection' 2 | 3 | export const validateStyles = ( 4 | element: HTMLElement, 5 | validStyles?: string 6 | ): void => { 7 | if (validStyles == null || element.style.length === 0) { 8 | return 9 | } 10 | 11 | validateCollection({ 12 | collection: element.style, 13 | validVariants: validStyles, 14 | onInvalid: (item) => { 15 | element.style.removeProperty(item) 16 | } 17 | }) 18 | 19 | if (element.style.length === 0) { 20 | element.removeAttribute('style') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /testing-utils/handleHTML.ts: -------------------------------------------------------------------------------- 1 | export interface HandleHTMLOptions { 2 | returnValue?: 'html' | 'result' 3 | } 4 | 5 | export const handleHTML = ( 6 | html: string, 7 | handler: (container: HTMLElement) => T, 8 | { returnValue = 'html' }: HandleHTMLOptions = {} 9 | ): string | T => { 10 | const container = document.createElement('div') 11 | container.innerHTML = html 12 | const result = handler(container) 13 | return returnValue === 'html' ? container.innerHTML : result 14 | } 15 | -------------------------------------------------------------------------------- /testing-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handleHTML' 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "sourceMap": true, 6 | "outDir": "./dist", 7 | "removeComments": true, 8 | "importsNotUsedAsValues": "remove", 9 | "noEmit": false 10 | }, 11 | "include": ["src"], 12 | "exclude": ["src/**/*.spec.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "resolveJsonModule": true, 8 | "noEmit": true, 9 | "isolatedModules": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "skipLibCheck": true, 16 | "paths": { 17 | "~/*": ["./src/*"], 18 | "testing-utils": ["./testing-utils"] 19 | } 20 | } 21 | } 22 | --------------------------------------------------------------------------------