├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── SECURITY.md ├── jest.config.cjs ├── package.json ├── rollup.config.js ├── src ├── states.ts └── striptags.ts ├── tests ├── states.test.ts └── striptags.test.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | ecmaVersion: 12, 11 | sourceType: "module", 12 | }, 13 | plugins: ["@typescript-eslint", "prettier"], 14 | rules: { 15 | indent: ["error", 4], 16 | "linebreak-style": ["error", "unix"], 17 | "prettier/prettier": "error", 18 | quotes: ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }], 19 | semi: ["error", "always"], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 14.x, 16.x, 18.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - run: yarn install 25 | 26 | - run: yarn test 27 | 28 | - run: yarn lint 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /dist/ 3 | /node_modules/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: false, 4 | tabWidth: 4, 5 | trailingComma: "all", 6 | }; 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2020] [Eric Norris] 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # striptags 2 | 3 | An implementation of PHP's [strip_tags](https://www.php.net/manual/en/function.strip-tags.php) in Typescript. 4 | 5 | **Note:** this is a total rewrite from [v3](https://github.com/ericnorris/striptags/tree/v3.x.x), and as such, is currently in an alpha state. Feel free to use this during the alpha period and provide feedback before it is released as v4. 6 | 7 | ## Highlights 8 | 9 | - No dependencies 10 | - Prevents XSS by default 11 | 12 | ## Installing 13 | 14 | ``` 15 | npm install striptags@alpha 16 | ``` 17 | 18 | ## Basic Usage 19 | 20 | ```typescript 21 | striptags(text: string, options?: Partial): string; 22 | ``` 23 | 24 | ### Examples 25 | 26 | ```javascript 27 | // commonjs 28 | const striptags = require("striptags").striptags; 29 | 30 | // alternatively, as an es6 import 31 | // import { striptags } from "striptags"; 32 | 33 | var html = ` 34 | lorem ipsum dolor sit amet 35 | `.trim(); 36 | 37 | console.log(striptags(html)); 38 | console.log(striptags(html, { allowedTags: new Set(["strong"]) })); 39 | console.log(striptags(html, { tagReplacementText: "🍩" })); 40 | ``` 41 | 42 | Outputs: 43 | 44 | ``` 45 | lorem ipsum dolor sit amet 46 | lorem ipsum dolor sit amet 47 | 🍩lorem ipsum 🍩dolor🍩 🍩sit🍩 amet🍩 48 | ``` 49 | 50 | ## Advanced Usage 51 | 52 | ```typescript 53 | class StateMachine { 54 | constructor(partialOptions?: Partial); 55 | consume(text: string): string; 56 | } 57 | ``` 58 | 59 | The `StateMachine` class is similar to the `striptags` function, but persists state across calls to `consume()` so that you may safely pass in a stream of text. For example: 60 | 61 | ```javascript 62 | // commonjs 63 | const StateMachine = require("striptags").StateMachine; 64 | 65 | // alternatively, as an es6 import 66 | // import { StateMachine } from "striptags"; 67 | 68 | const instance = new StateMachine(); 69 | 70 | console.log(instance.consume("some text with and more text")); 71 | ``` 72 | 73 | Outputs: 74 | 75 | ``` 76 | some text with and more text 77 | ``` 78 | 79 | ## Safety 80 | 81 | `striptags` is safe to use by default; the output is guaranteed to be free of potential XSS vectors if used as text within a tag. **Specifying either `allowedTags` or `disallowedTags` in the options argument removes this guarantee**, however. For example, a malicious user may achieve XSS via an attribute in an allowed tag: ``. 82 | 83 | In addition, `striptags` will automatically HTML encode `<` and `>` characters followed by whitespace. While most browsers tested treat `<` or `>` followed by whitespace as a non-tag string, it is safer to escape the characters. You may change this behavior via the `encodePlaintextTagDelimiters` option described below. 84 | 85 | ## `Partial` 86 | 87 | **`allowedTags?: Set`** 88 | 89 | A set containing a list of tag names to allow (e.g. `new Set(["tagname"])`). Tags not in this list will be removed. This option takes precedence over the `disallowedTags` option. 90 | 91 | Default: `undefined` 92 | 93 | **`disallowedTags?: Set`** 94 | 95 | A set containing a list of tag names to disallow ((e.g. `new Set(["tagname"])`). Tags not in this list will be allowed. Ignored if `allowedTags` is set. 96 | 97 | Default: `undefined` 98 | 99 | **`tagReplacementText?: string`** 100 | 101 | A string to use as replacement text when a tag is found and not allowed. 102 | 103 | Default: `""` 104 | 105 | **`encodePlaintextTagDelimiters?: boolean`** 106 | 107 | Setting this option to true will cause `<` and `>` characters immediately followed by whitespace to be HTML encoded. This is safe to set to `false` if the output is expected to be used only as plaintext (i.e. it will not be displayed alongside other HTML). 108 | 109 | Default: `true` 110 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ----------| ------------------ | 10 | | >= 4.0.0 | :white_check_mark: | 11 | | 3.2.0 | :white_check_mark: | 12 | | < 3.2.0 | :x: | 13 | 14 | ## Reporting a Vulnerability 15 | 16 | Contact me via my private github email address: [1906605+ericnorris@users.noreply.github.com](mailto:1906605+ericnorris@users.noreply.github.com) 17 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "striptags", 3 | "description": "PHP's strip_tags in Javascript", 4 | "license": "MIT", 5 | "author": "Eric Norris (https://github.com/ericnorris)", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/ericnorris/striptags.git" 9 | }, 10 | "homepage": "https://github.com/ericnorris/striptags", 11 | "bugs": "https://github.com/ericnorris/striptags/issues", 12 | "version": "4.0.0-alpha.4", 13 | "keywords": [ 14 | "striptags", 15 | "strip_tags", 16 | "html", 17 | "strip", 18 | "tags" 19 | ], 20 | "main": "dist/cjs/striptags.js", 21 | "types": "dist/es6/striptags.d.ts", 22 | "type": "module", 23 | "exports": { 24 | "import": "./dist/es6/striptags.js", 25 | "require": "./dist/cjs/striptags.cjs", 26 | "types": "./dist/es6/striptags.d.ts" 27 | }, 28 | "files": [ 29 | "dist/" 30 | ], 31 | "devDependencies": { 32 | "@rollup/plugin-typescript": "^6.1.0", 33 | "@types/jest": "^26.0.15", 34 | "@typescript-eslint/eslint-plugin": "^4.8.1", 35 | "@typescript-eslint/parser": "^4.8.1", 36 | "eslint": "^7.14.0", 37 | "eslint-plugin-prettier": "^3.1.4", 38 | "jest": "^26.6.1", 39 | "prettier": "^2.2.0", 40 | "rollup": "^2.33.3", 41 | "ts-jest": "^26.4.3", 42 | "tslib": "^2.0.3", 43 | "typescript": "^4.0.5" 44 | }, 45 | "scripts": { 46 | "test": "jest", 47 | "lint": "eslint .", 48 | "prepublishOnly": "eslint . && rollup -c" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | 3 | export default [ 4 | { 5 | input: "src/striptags.ts", 6 | output: [ 7 | { 8 | format: "es", 9 | dir: "dist/es6", 10 | }, 11 | ], 12 | plugins: [ 13 | typescript({ declaration: true, declarationDir: "dist/es6", outDir: "dist/es6" }), 14 | ], 15 | }, 16 | { 17 | input: "src/striptags.ts", 18 | output: [ 19 | { 20 | format: "cjs", 21 | dir: "dist/cjs", 22 | entryFileNames: "[name].cjs", 23 | exports: "named", 24 | }, 25 | ], 26 | plugins: [typescript()], 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/states.ts: -------------------------------------------------------------------------------- 1 | type SpaceCharacter = " " | "\n" | "\r" | "\t"; 2 | 3 | function isSpace(character: string): character is SpaceCharacter { 4 | return character == " " || character == "\n" || character == "\r" || character == "\t"; 5 | } 6 | 7 | type QuoteCharacter = '"' | "'"; 8 | 9 | function isQuote(character: string): character is QuoteCharacter { 10 | return character == '"' || character == "'"; 11 | } 12 | 13 | const TAG_START = "<"; 14 | const TAG_END = ">"; 15 | 16 | const ENCODED_TAG_START = "<"; 17 | const ENCODED_TAG_END = ">"; 18 | 19 | export interface StateMachineOptions { 20 | readonly allowedTags?: Set; 21 | readonly disallowedTags?: Set; 22 | 23 | readonly tagReplacementText: string; 24 | readonly encodePlaintextTagDelimiters: boolean; 25 | } 26 | 27 | export type StateTransitionFunction = (next: State) => void; 28 | 29 | export interface State { 30 | consume(character: string, transition: StateTransitionFunction): string; 31 | } 32 | 33 | type InPlaintextStateTransitionFunction = (next: InTagNameState) => void; 34 | 35 | export class InPlaintextState implements State { 36 | constructor(private readonly options: StateMachineOptions) {} 37 | 38 | consume(character: string, transition: InPlaintextStateTransitionFunction): string { 39 | if (character == TAG_START) { 40 | transition(new InTagNameState(this.options)); 41 | 42 | return ""; 43 | } else if (character == TAG_END && this.options.encodePlaintextTagDelimiters) { 44 | return ENCODED_TAG_END; 45 | } 46 | 47 | return character; 48 | } 49 | } 50 | 51 | export const enum TagMode { 52 | Allowed, 53 | Disallowed, 54 | } 55 | 56 | type InTagNameStateTransitionFunction = ( 57 | next: 58 | | InPlaintextState 59 | | InTagState 60 | | InTagState 61 | | InCommentState, 62 | ) => void; 63 | 64 | export class InTagNameState implements State { 65 | private nameBuffer = ""; 66 | private isClosingTag = false; 67 | 68 | constructor(private readonly options: StateMachineOptions) {} 69 | 70 | consume(character: string, transition: InTagNameStateTransitionFunction): string { 71 | if (this.nameBuffer.length == 0) { 72 | if (isSpace(character)) { 73 | transition(new InPlaintextState(this.options)); 74 | 75 | return ( 76 | (this.options.encodePlaintextTagDelimiters ? ENCODED_TAG_START : "<") + 77 | character 78 | ); 79 | } 80 | 81 | if (character == "/") { 82 | this.isClosingTag = true; 83 | 84 | return ""; 85 | } 86 | } 87 | 88 | if (isSpace(character)) { 89 | if (this.isNameBufferAnAllowedTag()) { 90 | transition(new InTagState(TagMode.Allowed, this.options)); 91 | 92 | return TAG_START + (this.isClosingTag ? "/" : "") + this.nameBuffer + character; 93 | } else { 94 | transition(new InTagState(TagMode.Disallowed, this.options)); 95 | 96 | return this.options.tagReplacementText; 97 | } 98 | } 99 | 100 | if (character == TAG_START) { 101 | this.nameBuffer += ENCODED_TAG_START; 102 | 103 | return ""; 104 | } 105 | 106 | if (character == TAG_END) { 107 | transition(new InPlaintextState(this.options)); 108 | 109 | if (this.isNameBufferAnAllowedTag()) { 110 | return TAG_START + (this.isClosingTag ? "/" : "") + this.nameBuffer + character; 111 | } else { 112 | return this.options.tagReplacementText; 113 | } 114 | } 115 | 116 | if (character == "-" && this.nameBuffer == "!-") { 117 | transition(new InCommentState(this.options)); 118 | 119 | return ""; 120 | } 121 | 122 | this.nameBuffer += character; 123 | 124 | return ""; 125 | } 126 | 127 | private isNameBufferAnAllowedTag(): boolean { 128 | const tagName = this.nameBuffer.toLowerCase(); 129 | 130 | if (this.options.allowedTags) { 131 | return this.options.allowedTags.has(tagName); 132 | } else if (this.options.disallowedTags) { 133 | return !this.options.disallowedTags.has(tagName); 134 | } else { 135 | return false; 136 | } 137 | } 138 | } 139 | 140 | type InTagStateTransitionFunction = ( 141 | next: InPlaintextState | InQuotedStringInTagState, 142 | ) => void; 143 | 144 | export class InTagState implements State { 145 | constructor(public readonly mode: T, private readonly options: StateMachineOptions) {} 146 | 147 | consume(character: string, transition: InTagStateTransitionFunction): string { 148 | if (character == TAG_END) { 149 | transition(new InPlaintextState(this.options)); 150 | } else if (isQuote(character)) { 151 | transition(new InQuotedStringInTagState(this.mode, character, this.options)); 152 | } 153 | 154 | if (this.mode == TagMode.Disallowed) { 155 | return ""; 156 | } 157 | 158 | if (character == TAG_START) { 159 | return ENCODED_TAG_START; 160 | } else { 161 | return character; 162 | } 163 | } 164 | } 165 | 166 | type InQuotedStringInTagStateTransitionFunction = (next: InTagState) => void; 167 | 168 | export class InQuotedStringInTagState implements State { 169 | constructor( 170 | public readonly mode: T, 171 | public readonly quoteCharacter: QuoteCharacter, 172 | private readonly options: StateMachineOptions, 173 | ) {} 174 | 175 | consume(character: string, transition: InQuotedStringInTagStateTransitionFunction): string { 176 | if (character == this.quoteCharacter) { 177 | transition(new InTagState(this.mode, this.options)); 178 | } 179 | 180 | if (this.mode == TagMode.Disallowed) { 181 | return ""; 182 | } 183 | 184 | if (character == TAG_START) { 185 | return ENCODED_TAG_START; 186 | } else if (character == TAG_END) { 187 | return ENCODED_TAG_END; 188 | } else { 189 | return character; 190 | } 191 | } 192 | } 193 | 194 | type InCommentStateTransitionFunction = (next: InPlaintextState) => void; 195 | 196 | export class InCommentState implements State { 197 | private consecutiveHyphens = 0; 198 | 199 | constructor(private readonly options: StateMachineOptions) {} 200 | 201 | consume(character: string, transition: InCommentStateTransitionFunction): string { 202 | if (character == ">" && this.consecutiveHyphens >= 2) { 203 | transition(new InPlaintextState(this.options)); 204 | } else if (character == "-") { 205 | this.consecutiveHyphens++; 206 | } else { 207 | this.consecutiveHyphens = 0; 208 | } 209 | 210 | return ""; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/striptags.ts: -------------------------------------------------------------------------------- 1 | import { StateMachineOptions, State, StateTransitionFunction, InPlaintextState } from "./states"; 2 | 3 | export { StateMachineOptions } from "./states"; 4 | 5 | export const DefaultStateMachineOptions: StateMachineOptions = { 6 | tagReplacementText: "", 7 | encodePlaintextTagDelimiters: true, 8 | }; 9 | 10 | export class StateMachine { 11 | private state: State; 12 | 13 | private transitionFunction: StateTransitionFunction; 14 | 15 | constructor(partialOptions: Partial = {}) { 16 | this.state = new InPlaintextState({ 17 | ...DefaultStateMachineOptions, 18 | ...partialOptions, 19 | }); 20 | 21 | this.transitionFunction = ((next: State): void => { 22 | this.state = next; 23 | }).bind(this); 24 | } 25 | 26 | public consume(text: string): string { 27 | let outputBuffer = ""; 28 | 29 | for (const character of text) { 30 | outputBuffer += this.state.consume(character, this.transitionFunction); 31 | } 32 | 33 | return outputBuffer; 34 | } 35 | } 36 | 37 | export function striptags(text: string, options: Partial = {}): string { 38 | return new StateMachine(options).consume(text); 39 | } 40 | 41 | export default striptags; 42 | -------------------------------------------------------------------------------- /tests/states.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StateMachineOptions, 3 | State, 4 | TagMode, 5 | InPlaintextState, 6 | InTagNameState, 7 | InTagState, 8 | InCommentState, 9 | InQuotedStringInTagState, 10 | } from "../src/states"; 11 | 12 | const AllowedTagName = "allowed"; 13 | const DisallowedTagName = "disallowed"; 14 | const ImplicitlyAllowedTagName = "notdisallowed"; 15 | const TagReplacementText = "~replaced~"; 16 | 17 | const DefaultTestOptions = { 18 | allowedTags: new Set([AllowedTagName]), 19 | tagReplacementText: TagReplacementText, 20 | }; 21 | 22 | const OptionsWithEncodingEnabled: StateMachineOptions = { 23 | ...DefaultTestOptions, 24 | encodePlaintextTagDelimiters: true, 25 | }; 26 | 27 | const OptionsWithEncodingDisabled: StateMachineOptions = { 28 | ...DefaultTestOptions, 29 | encodePlaintextTagDelimiters: false, 30 | }; 31 | 32 | const OptionsWithDisallowedTags: StateMachineOptions = { 33 | disallowedTags: new Set([DisallowedTagName]), 34 | tagReplacementText: TagReplacementText, 35 | encodePlaintextTagDelimiters: true, 36 | }; 37 | 38 | function consumeStringUntilTransitionOrEOF(start: State, text: string): [string, State] { 39 | let currentState = start; 40 | let outputBuffer = ""; 41 | 42 | for (const character of text) { 43 | outputBuffer += start.consume(character, (next: State) => { 44 | currentState = next; 45 | }); 46 | 47 | if (currentState != start) { 48 | break; 49 | } 50 | } 51 | 52 | return [outputBuffer, currentState]; 53 | } 54 | 55 | describe("InPlaintextState", () => { 56 | it("should transition upon seeing a '<' character", () => { 57 | const start = new InPlaintextState(OptionsWithEncodingEnabled); 58 | 59 | const text = "a string ' characters", () => { 69 | const start = new InPlaintextState(OptionsWithEncodingEnabled); 70 | 71 | const text = "a string >"; 72 | const want = "a string >"; 73 | 74 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 75 | 76 | expect(got).toEqual(want); 77 | expect(endState).toBeInstanceOf(InPlaintextState); 78 | }); 79 | 80 | it("should not encode spurious '>' characters", () => { 81 | const start = new InPlaintextState(OptionsWithEncodingDisabled); 82 | 83 | const text = "a string >"; 84 | const want = text; 85 | 86 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 87 | 88 | expect(got).toEqual(want); 89 | expect(endState).toBeInstanceOf(InPlaintextState); 90 | }); 91 | }); 92 | 93 | describe("InTagNameState", () => { 94 | it("should output an encoded '<' character if immediately followed by a space", () => { 95 | const start = new InTagNameState(OptionsWithEncodingEnabled); 96 | 97 | const text = " "; 98 | const want = `<${text}`; 99 | 100 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 101 | 102 | expect(got).toEqual(want); 103 | expect(endState).toBeInstanceOf(InPlaintextState); 104 | }); 105 | 106 | it("should output a '<' character if immediately followed by a space", () => { 107 | const start = new InTagNameState(OptionsWithEncodingDisabled); 108 | 109 | const text = " "; 110 | const want = `<${text}`; 111 | 112 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, " "); 113 | 114 | expect(got).toEqual(want); 115 | expect(endState).toBeInstanceOf(InPlaintextState); 116 | }); 117 | 118 | it("should transition to InTagState w/ allowed mode upon seeing a space", () => { 119 | const start = new InTagNameState(OptionsWithEncodingEnabled); 120 | 121 | const text = `${AllowedTagName} `; 122 | const want = `<${text}`; 123 | 124 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 125 | 126 | expect(got).toEqual(want); 127 | expect(endState).toBeInstanceOf(InTagState); 128 | expect(endState).toHaveProperty("mode", TagMode.Allowed); 129 | }); 130 | 131 | it("should transition to InTagState w/ allowed mode upon seeing a space after a closing tag name", () => { 132 | const start = new InTagNameState(OptionsWithEncodingEnabled); 133 | 134 | const text = `/${AllowedTagName} `; 135 | const want = `<${text}`; 136 | 137 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 138 | 139 | expect(got).toEqual(want); 140 | expect(endState).toBeInstanceOf(InTagState); 141 | expect(endState).toHaveProperty("mode", TagMode.Allowed); 142 | }); 143 | 144 | it("should transition to InTagState w/ disallowed mode upon seeing a space", () => { 145 | const start = new InTagNameState(OptionsWithEncodingEnabled); 146 | 147 | const text = "disallowed "; 148 | const want = TagReplacementText; 149 | 150 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 151 | 152 | expect(got).toEqual(want); 153 | expect(endState).toBeInstanceOf(InTagState); 154 | expect(endState).toHaveProperty("mode", TagMode.Disallowed); 155 | }); 156 | 157 | it("should allow tags with no attributes", () => { 158 | const start = new InTagNameState(OptionsWithEncodingEnabled); 159 | 160 | const text = `${AllowedTagName}>`; 161 | const want = `<${text}`; 162 | 163 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 164 | 165 | expect(got).toEqual(want); 166 | expect(endState).toBeInstanceOf(InPlaintextState); 167 | }); 168 | 169 | it("should allow closing tags with no attributes", () => { 170 | const start = new InTagNameState(OptionsWithEncodingEnabled); 171 | 172 | const text = `/${AllowedTagName}>`; 173 | const want = `<${text}`; 174 | 175 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 176 | 177 | expect(got).toEqual(want); 178 | expect(endState).toBeInstanceOf(InPlaintextState); 179 | }); 180 | 181 | it("should disallow tags with no attributes", () => { 182 | const start = new InTagNameState(OptionsWithEncodingEnabled); 183 | 184 | const text = "disallowed>"; 185 | const want = TagReplacementText; 186 | 187 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 188 | 189 | expect(got).toEqual(want); 190 | expect(endState).toBeInstanceOf(InPlaintextState); 191 | }); 192 | 193 | it("should disallow tags in a disallowedTags set", () => { 194 | const start = new InTagNameState(OptionsWithDisallowedTags); 195 | 196 | const text = `${DisallowedTagName} `; 197 | const want = TagReplacementText; 198 | 199 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 200 | 201 | expect(got).toEqual(want); 202 | expect(endState).toBeInstanceOf(InTagState); 203 | expect(endState).toHaveProperty("mode", TagMode.Disallowed); 204 | }); 205 | 206 | it("should allow tags not in a disallowedTags set", () => { 207 | const start = new InTagNameState(OptionsWithDisallowedTags); 208 | 209 | const text = `${ImplicitlyAllowedTagName} `; 210 | const want = `<${text}`; 211 | 212 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 213 | 214 | expect(got).toEqual(want); 215 | expect(endState).toBeInstanceOf(InTagState); 216 | expect(endState).toHaveProperty("mode", TagMode.Allowed); 217 | }); 218 | 219 | it("should transition to InCommentState for comments", () => { 220 | const start = new InTagNameState(OptionsWithEncodingEnabled); 221 | 222 | const text = "!-- a comment"; 223 | const want = ""; 224 | 225 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 226 | 227 | expect(got).toEqual(want); 228 | expect(endState).toBeInstanceOf(InCommentState); 229 | }); 230 | 231 | it("should encode '<' characters within the tag name", () => { 232 | const start = new InTagNameState({ 233 | ...OptionsWithEncodingEnabled, 234 | allowedTags: new Set(["<<<"]), 235 | }); 236 | 237 | const text = "<<<>"; 238 | const want = `<<<<>`; 239 | 240 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 241 | 242 | expect(got).toEqual(want); 243 | expect(endState).toBeInstanceOf(InPlaintextState); 244 | }); 245 | }); 246 | 247 | describe("InTagState", () => { 248 | it("should return text if allowed", () => { 249 | const start = new InTagState(TagMode.Allowed, OptionsWithEncodingEnabled); 250 | 251 | const text = "tag body text"; 252 | const want = text; 253 | 254 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 255 | 256 | expect(got).toEqual(want); 257 | expect(endState).toBeInstanceOf(InTagState); 258 | }); 259 | 260 | it("should not return text if disallowed", () => { 261 | const start = new InTagState(TagMode.Disallowed, OptionsWithEncodingEnabled); 262 | 263 | const text = "tag body < text"; 264 | const want = ""; 265 | 266 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 267 | 268 | expect(got).toEqual(want); 269 | expect(endState).toBeInstanceOf(InTagState); 270 | }); 271 | 272 | describe("should transition to InQuotedStringInTagState upon seeing a quote character", () => { 273 | it('" character', () => { 274 | const start = new InTagState(TagMode.Disallowed, OptionsWithEncodingEnabled); 275 | 276 | const text = 'attr="'; 277 | const want = ""; 278 | 279 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 280 | 281 | expect(got).toEqual(want); 282 | expect(endState).toBeInstanceOf(InQuotedStringInTagState); 283 | expect(endState).toHaveProperty("mode", TagMode.Disallowed); 284 | expect(endState).toHaveProperty("quoteCharacter", '"'); 285 | }); 286 | 287 | it("' character", () => { 288 | const start = new InTagState(TagMode.Allowed, OptionsWithEncodingEnabled); 289 | 290 | const text = "attr='"; 291 | const want = "attr='"; 292 | 293 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 294 | 295 | expect(got).toEqual(want); 296 | expect(endState).toBeInstanceOf(InQuotedStringInTagState); 297 | expect(endState).toHaveProperty("mode", TagMode.Allowed); 298 | expect(endState).toHaveProperty("quoteCharacter", "'"); 299 | }); 300 | }); 301 | 302 | it("should encode spurious '<' characters", () => { 303 | const start = new InTagState(TagMode.Allowed, OptionsWithEncodingEnabled); 304 | 305 | const text = "tag body < text"; 306 | const want = "tag body < text"; 307 | 308 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 309 | 310 | expect(got).toEqual(want); 311 | expect(endState).toBeInstanceOf(InTagState); 312 | }); 313 | 314 | it("should transition to InPlaintextState upon seeing a '>' character", () => { 315 | const start = new InTagState(TagMode.Allowed, OptionsWithEncodingEnabled); 316 | 317 | const text = "tag body> text"; 318 | const want = "tag body>"; 319 | 320 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 321 | 322 | expect(got).toEqual(want); 323 | expect(endState).toBeInstanceOf(InPlaintextState); 324 | }); 325 | }); 326 | 327 | describe("InQuotedStringInTagState", () => { 328 | it("should return text if allowed", () => { 329 | const start = new InQuotedStringInTagState( 330 | TagMode.Allowed, 331 | "'", 332 | OptionsWithEncodingEnabled, 333 | ); 334 | 335 | const text = 'attr body " text < >'; 336 | const want = 'attr body " text < >'; 337 | 338 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 339 | 340 | expect(got).toEqual(want); 341 | expect(endState).toBeInstanceOf(InQuotedStringInTagState); 342 | }); 343 | 344 | it("should not return text if disallowed", () => { 345 | const start = new InQuotedStringInTagState( 346 | TagMode.Disallowed, 347 | "'", 348 | OptionsWithEncodingEnabled, 349 | ); 350 | 351 | const text = "attr body text < >"; 352 | const want = ""; 353 | 354 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 355 | 356 | expect(got).toEqual(want); 357 | expect(endState).toBeInstanceOf(InQuotedStringInTagState); 358 | }); 359 | 360 | describe("should transition back to InTagState upon seeing a closing quote character", () => { 361 | it('" character', () => { 362 | const start = new InQuotedStringInTagState( 363 | TagMode.Allowed, 364 | '"', 365 | OptionsWithEncodingEnabled, 366 | ); 367 | 368 | const text = 'attr body text" '; 369 | const want = 'attr body text"'; 370 | 371 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 372 | 373 | expect(got).toEqual(want); 374 | expect(endState).toBeInstanceOf(InTagState); 375 | expect(endState).toHaveProperty("mode", TagMode.Allowed); 376 | }); 377 | 378 | it("' character", () => { 379 | const start = new InQuotedStringInTagState( 380 | TagMode.Disallowed, 381 | "'", 382 | OptionsWithEncodingEnabled, 383 | ); 384 | 385 | const text = "attr body text' "; 386 | const want = ""; 387 | 388 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 389 | 390 | expect(got).toEqual(want); 391 | expect(endState).toBeInstanceOf(InTagState); 392 | expect(endState).toHaveProperty("mode", TagMode.Disallowed); 393 | }); 394 | }); 395 | }); 396 | 397 | describe("InCommentState", () => { 398 | it("should ignore extra hyphens", () => { 399 | const start = new InCommentState(OptionsWithEncodingEnabled); 400 | 401 | const text = "-a- -"; 402 | const want = ""; 403 | 404 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 405 | 406 | expect(got).toEqual(want); 407 | expect(endState).toBeInstanceOf(InCommentState); 408 | }); 409 | 410 | it("should transition back to InPlaintextState upon seeing a closing comment tag", () => { 411 | const start = new InCommentState(OptionsWithEncodingEnabled); 412 | 413 | const text = "some text --->"; 414 | const want = ""; 415 | 416 | const [got, endState] = consumeStringUntilTransitionOrEOF(start, text); 417 | 418 | expect(got).toEqual(want); 419 | expect(endState).toBeInstanceOf(InPlaintextState); 420 | }); 421 | }); 422 | -------------------------------------------------------------------------------- /tests/striptags.test.ts: -------------------------------------------------------------------------------- 1 | import { StateMachineOptions, StateMachine, striptags } from "../src/striptags"; 2 | 3 | const OptionsWithAllowedTags: Partial = { 4 | allowedTags: new Set(["atag"]), 5 | tagReplacementText: " ", 6 | }; 7 | 8 | const OptionsWithDisallowedTags: Partial = { 9 | disallowedTags: new Set(["atag"]), 10 | tagReplacementText: " ", 11 | }; 12 | 13 | const ExampleText = `some textmore text`; 14 | 15 | const WantWhenUsingDefault = "some textmore text"; 16 | const WantWhenUsingAllowedTags = `some text more text `; 17 | const WantWhenUsingDisallowedTags = " some textmore text "; 18 | 19 | describe("StateMachine", () => { 20 | it("defaults sanity check", () => { 21 | const machine = new StateMachine(); 22 | 23 | const got = ExampleText.split(/(?= )/g) 24 | .map((partial) => machine.consume(partial)) 25 | .join(""); 26 | 27 | expect(got).toEqual(WantWhenUsingDefault); 28 | }); 29 | 30 | it("allowed tags sanity check", () => { 31 | const machine = new StateMachine(OptionsWithAllowedTags); 32 | 33 | const got = ExampleText.split(/(?= )/g) 34 | .map((partial) => machine.consume(partial)) 35 | .join(""); 36 | 37 | expect(got).toEqual(WantWhenUsingAllowedTags); 38 | }); 39 | 40 | it("disallowed tags sanity check", () => { 41 | const machine = new StateMachine(OptionsWithDisallowedTags); 42 | 43 | const got = ExampleText.split(/(?= )/g) 44 | .map((partial) => machine.consume(partial)) 45 | .join(""); 46 | 47 | expect(got).toEqual(WantWhenUsingDisallowedTags); 48 | }); 49 | }); 50 | 51 | describe("striptags", () => { 52 | it("defaults sanity check", () => { 53 | const got = striptags(ExampleText); 54 | 55 | expect(got).toEqual(WantWhenUsingDefault); 56 | }); 57 | 58 | it("allowed tags sanity check", () => { 59 | const got = striptags(ExampleText, OptionsWithAllowedTags); 60 | 61 | expect(got).toEqual(WantWhenUsingAllowedTags); 62 | }); 63 | 64 | it("disallowed tags sanity check", () => { 65 | const got = striptags(ExampleText, OptionsWithDisallowedTags); 66 | 67 | expect(got).toEqual(WantWhenUsingDisallowedTags); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2015", 5 | 6 | "strict": true, 7 | 8 | "rootDir": "src/", 9 | 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | 15 | "moduleResolution": "node", 16 | "esModuleInterop": true, 17 | 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "include": ["src/*"] 22 | } 23 | --------------------------------------------------------------------------------