├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .lintstagedrc.js ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── attributes.ts └── index.ts ├── test └── index.test.ts ├── tsconfig.json └── vitest.config.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ["eslint:recommended", "prettier"], 7 | parserOptions: { 8 | ecmaVersion: 12, 9 | sourceType: "module", 10 | }, 11 | rules: { 12 | "no-console": "error", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm test 17 | 18 | publish-npm: 19 | needs: test 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v2 24 | with: 25 | node-version: 12 26 | registry-url: https://registry.npmjs.org/ 27 | - run: npm ci 28 | - run: npm run build 29 | - run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x, 14.x, 16.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm run format:check 21 | - run: npm run type:check 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test 5 | npm run pre-commit 6 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.ts": [ 3 | // https://github.com/okonet/lint-staged/issues/825 4 | () => "tsc --noEmit", 5 | "prettier --write", 6 | ], 7 | "*.js": ["prettier --write", "eslint --fix"], 8 | }; 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Antonio Villagra De La Cruz 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 | # html-template-tag 2 | 3 | [![version](https://img.shields.io/npm/v/html-template-tag.svg)](http://npm.im/html-template-tag) 4 | [![issues](https://img.shields.io/github/issues-raw/antoniovdlc/html-template-tag.svg)](https://github.com/AntonioVdlC/html-template-tag/issues) 5 | [![downloads](https://img.shields.io/npm/dt/html-template-tag.svg)](http://npm.im/html-template-tag) 6 | [![license](https://img.shields.io/npm/l/html-template-tag.svg)](http://opensource.org/licenses/MIT) 7 | 8 | ES6 Tagged Template for compiling HTML template strings. 9 | 10 | ## Installation 11 | 12 | This package is distributed via npm: 13 | 14 | ``` 15 | npm install html-template-tag 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### String Interpolation 21 | 22 | At its core, this module just performs simple ES6 string interpolation. 23 | 24 | ```javascript 25 | var html = require("html-template-tag"); 26 | // - or - import html from "html-template-tag"; 27 | 28 | var name = `Antonio`; 29 | var string = html`Hello, ${name}!`; 30 | // "Hello, Antonio!" 31 | ``` 32 | 33 | Nevertheless, it escapes HTML special characters without refraining its use in loops! 34 | 35 | ```javascript 36 | var html = require("html-template-tag"); 37 | // - or - import html from "html-template-tag"; 38 | 39 | var names = ["Antonio", "Megan", "/>"]; 40 | var string = html` 41 | 44 | `; 45 | // "" 46 | ``` 47 | 48 | ### Skip autoscaping 49 | 50 | You can use double dollar signs in interpolation to mark the value as safe (which means that this variable will not be escaped). 51 | 52 | ```javascript 53 | var name = `Antonio`; 54 | var string = html`Hello, $${name}!`; 55 | // "Hello, Antonio!" 56 | ``` 57 | 58 | ### HTML Template Pre-Compiling 59 | 60 | This small module can also be used to pre-compile HTML templates: 61 | 62 | ```javascript 63 | var html = require("html-template-tag"); 64 | // - or - import html from "html-template-tag"; 65 | 66 | var data = { 67 | count: 2, 68 | names: ["Antonio", "Megan"], 69 | }; 70 | 71 | var template = ({ names }) => html` 72 | 75 | `; 76 | 77 | var string = template(data); 78 | /* 79 | " 80 | 84 | " 85 | */ 86 | ``` 87 | 88 | > NB: The formating of the string literal is kept. 89 | 90 | ### Interpolation inside URI attributes 91 | 92 | To avoid XSS attacks, this package removes all interpolation instide URI attributes ([more info](https://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html)). This package also ensures that interpolations inside attributes are properly escaped. 93 | 94 | ## License 95 | 96 | MIT 97 | 98 | ## Thanks 99 | 100 | The code for this module has been heavily inspired on [Axel Rauschmayer's post on HTML templating with ES6 template strings](http://www.2ality.com/2015/01/template-strings-html.html) and [Stefan Bieschewski's comment](http://www.2ality.com/2015/01/template-strings-html.html#comment-2078932192). 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-template-tag", 3 | "version": "4.1.1", 4 | "description": "ES6 Tagged Template for compiling HTML template strings.", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": { 11 | "types": "./dist/index.d.mts", 12 | "default": "./dist/index.esm.js" 13 | }, 14 | "require": { 15 | "types": "./dist/index.d.ts", 16 | "default": "./dist/index.cjs.js" 17 | } 18 | } 19 | }, 20 | "files": [ 21 | "dist/index.cjs.js", 22 | "dist/index.esm.js", 23 | "dist/index.d.ts", 24 | "dist/index.d.mts" 25 | ], 26 | "scripts": { 27 | "prepare": "husky install", 28 | "type:check": "tsc src/*.ts --noEmit", 29 | "format": "prettier --write --ignore-unknown {src,test}/*", 30 | "format:check": "prettier --check {src,test}/*", 31 | "test": "vitest run --coverage", 32 | "pre-commit": "lint-staged", 33 | "prebuild": "rimraf dist && mkdir dist", 34 | "build": "npm run build:types && npm run build:lib", 35 | "build:types": "tsc src/*.ts --declaration --emitDeclarationOnly --outDir dist && cp dist/index.d.ts dist/index.d.mts", 36 | "build:lib": "rollup -c", 37 | "postversion": "git push && git push --tags" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/AntonioVdlC/html-template-tag.git" 42 | }, 43 | "keywords": [ 44 | "html", 45 | "template", 46 | "tag", 47 | "es6", 48 | "string", 49 | "literals" 50 | ], 51 | "author": "Antonio Villagra De La Cruz", 52 | "license": "MIT", 53 | "bugs": { 54 | "url": "https://github.com/AntonioVdlC/html-template-tag/issues" 55 | }, 56 | "homepage": "https://github.com/AntonioVdlC/html-template-tag#readme", 57 | "devDependencies": { 58 | "@rollup/plugin-typescript": "^8.2.0", 59 | "c8": "^7.11.0", 60 | "eslint": "^8.13.0", 61 | "eslint-config-prettier": "^8.5.0", 62 | "husky": "^7.0.2", 63 | "lint-staged": "^13.1.0", 64 | "prettier": "^2.2.1", 65 | "rimraf": "^3.0.2", 66 | "rollup": "^2.41.4", 67 | "rollup-plugin-terser": "^7.0.2", 68 | "tslib": "^2.1.0", 69 | "typescript": "^4.2.3", 70 | "vite": "^2.9.1", 71 | "vitest": "^0.9.3" 72 | }, 73 | "dependencies": { 74 | "html-element-attributes": "^3.4.0", 75 | "html-es6cape": "^2.0.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import { terser } from "rollup-plugin-terser"; 3 | 4 | export default [ 5 | { 6 | input: "src/index.ts", 7 | output: { 8 | file: "dist/index.cjs.js", 9 | format: "cjs", 10 | exports: "default", 11 | }, 12 | external: ["html-es6cape"], 13 | plugins: [typescript(), terser()], 14 | }, 15 | { 16 | input: "src/index.ts", 17 | output: { 18 | file: "dist/index.esm.js", 19 | format: "es", 20 | }, 21 | external: ["html-es6cape"], 22 | plugins: [typescript(), terser()], 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/attributes.ts: -------------------------------------------------------------------------------- 1 | import { htmlElementAttributes } from "html-element-attributes"; 2 | 3 | export const attributes = Object.values(htmlElementAttributes).flat(); 4 | 5 | export function endsWithUnescapedAttribute(acc: string): boolean { 6 | return attributes.some((attribute) => acc.endsWith(`${attribute}=`)); 7 | } 8 | 9 | export const attributesUri = [ 10 | "action", 11 | "archive", 12 | "background", 13 | "cite", 14 | "classid", 15 | "codebase", 16 | "content", 17 | "data", 18 | "dynsrc", 19 | "formaction", 20 | "href", 21 | "icon", 22 | "imagesrcset", 23 | "longdesc", 24 | "lowsrc", 25 | "manifest", 26 | "nohref", 27 | "onload", 28 | "poster", 29 | "popovertargetaction", 30 | "profile", 31 | "src", 32 | "srcset", 33 | "style", 34 | "usemap", 35 | ]; 36 | 37 | export const endsWithUriAttribute = function endsWithAttributes( 38 | acc: string 39 | ): boolean { 40 | return attributesUri.some( 41 | (attribute) => 42 | acc.endsWith(`${attribute}=`) || acc.endsWith(`${attribute}="`) 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Inspired on http://www.2ality.com/2015/01/template-strings-html.html#comment-2078932192 2 | 3 | import escape from "html-es6cape"; 4 | import { endsWithUnescapedAttribute, endsWithUriAttribute } from "./attributes"; 5 | 6 | function htmlTemplateTag( 7 | literals: TemplateStringsArray, 8 | ...substs: Array 9 | ): string { 10 | return literals.raw.reduce((acc, lit, i) => { 11 | let subst = substs[i - 1]; 12 | if (Array.isArray(subst)) { 13 | subst = subst.join(""); 14 | } else if (literals.raw[i - 1] && literals.raw[i - 1].endsWith("$")) { 15 | // If the interpolation is preceded by a dollar sign, 16 | // substitution is considered safe and will not be escaped 17 | acc = acc.slice(0, -1); 18 | } else { 19 | subst = escape(subst); 20 | } 21 | 22 | /** 23 | * If the interpolation is preceded by an unescaped attribute, we need to 24 | * add quotes around the substitution to avoid XSS attacks. 25 | * 26 | * ``` 27 | * const foo = "Alt onload=alert(1)"; 28 | * html`${foo}` 29 | * => Alt 30 | * ``` 31 | */ 32 | if (endsWithUnescapedAttribute(acc)) { 33 | acc += '"'; 34 | lit = '"' + lit; 35 | } 36 | 37 | /** 38 | * If the interpolation is preceded by an attribute that takes an URI, we 39 | * remove the interpolation altogether as it can pose serious security 40 | * vulnerabilities. 41 | * 42 | * A warning is displayed in the console. 43 | */ 44 | if (endsWithUriAttribute(acc)) { 45 | console.warn( 46 | "[html-template-tag] Trying to interpolate inside an URI attribute. This can lead to security vulnerabilities. The interpolation has been removed.", 47 | { acc, subst, lit } 48 | ); 49 | 50 | subst = ""; 51 | } 52 | 53 | return acc + subst + lit; 54 | }); 55 | } 56 | 57 | export default htmlTemplateTag; 58 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from "vitest"; 2 | 3 | import html from "../src"; 4 | import { attributes, attributesUri } from "../src/attributes"; 5 | 6 | const attributesNoUri = attributes.filter( 7 | (attribute) => !attributesUri.includes(attribute) 8 | ); 9 | 10 | describe("html-template-tag", () => { 11 | const consoleWarnSpy = vi 12 | .spyOn(console, "warn") 13 | .mockImplementation(() => undefined); 14 | 15 | beforeEach(() => { 16 | consoleWarnSpy.mockReset(); 17 | }); 18 | 19 | it("should return a string when passed a string literal", () => { 20 | expect(typeof html`Hello, world!`).toEqual("string"); 21 | }); 22 | 23 | it("should preserve the string literal value", () => { 24 | expect(html`Hello, world!`).toEqual("Hello, world!"); 25 | }); 26 | 27 | it("should interpolate variables", () => { 28 | const name = "Antonio"; 29 | expect(html`Hello, ${name}!`).toEqual("Hello, Antonio!"); 30 | }); 31 | 32 | it("should escape HTML special characters", () => { 33 | const chars: Record = { 34 | "&": "&", 35 | ">": ">", 36 | "<": "<", 37 | '"': """, 38 | "'": "'", 39 | "`": "`", 40 | }; 41 | 42 | Object.keys(chars).forEach((key) => { 43 | expect(html`${key}`).toEqual(chars[key]); 44 | }); 45 | }); 46 | 47 | it("should skip escaping HTML special characters for substituitions with double $", () => { 48 | const safeString = "Antonio"; 49 | expect(html`Hello, $${safeString}!`).toEqual( 50 | "Hello, Antonio!" 51 | ); 52 | }); 53 | 54 | it("should escape HTML special characters if previous substituition ended with $", () => { 55 | const insertedDollar = "I :heart: $"; 56 | const unsafeString = " & €"; 57 | const emptyString = ""; 58 | expect(html`${insertedDollar}${unsafeString}!`).toEqual( 59 | "I :heart: $ & €!" 60 | ); 61 | expect(html`${insertedDollar}${emptyString}${unsafeString}!`).toEqual( 62 | "I :heart: $ & €!" 63 | ); 64 | expect(html`${insertedDollar}$${emptyString}${unsafeString}!`).toEqual( 65 | "I :heart: $ & €!" 66 | ); 67 | expect(html`$${insertedDollar}${emptyString}${unsafeString}!`).toEqual( 68 | "I :heart: $ & €!" 69 | ); 70 | }); 71 | 72 | it("should generate valid HTML with an array of values", () => { 73 | const names = ["Megan", "Tiphaine", "Florent", "Hoan"]; 74 | 75 | expect( 76 | html`
77 | My best friends are: 78 |
    79 | ${names.map((name) => html`
  • ${name}
  • `)} 80 |
81 |
` 82 | ).toEqual( 83 | `
84 | My best friends are: 85 |
    86 |
  • Megan
  • Tiphaine
  • Florent
  • Hoan
  • 87 |
88 |
` 89 | ); 90 | }); 91 | 92 | it.each(attributesNoUri)( 93 | "should add quotes around attribute '%s'", 94 | (attribute) => { 95 | const value = "Alt onload=alert(1)"; 96 | expect(html`
`).toEqual( 97 | `
` 98 | ); 99 | } 100 | ); 101 | 102 | it.each(attributesNoUri)( 103 | "should not add quotes around attribute '%s' if they are already present", 104 | (attribute) => { 105 | const value = "Alt onload=alert(1)"; 106 | expect(html`
`).toEqual( 107 | `
` 108 | ); 109 | } 110 | ); 111 | 112 | it.each(attributesUri)( 113 | "should remove the interpolation if preceeded by an attribute that takes a URI ('%s')", 114 | (attribute) => { 115 | const value = "some string"; 116 | expect(html`
`).toEqual( 117 | `
` 118 | ); 119 | expect(consoleWarnSpy).toHaveBeenCalledWith( 120 | "[html-template-tag] Trying to interpolate inside an URI attribute. This can lead to security vulnerabilities. The interpolation has been removed.", 121 | { acc: `
` } 122 | ); 123 | } 124 | ); 125 | }); 126 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ["text", "json", "html"], 7 | lines: 100, 8 | functions: 100, 9 | branches: 100, 10 | }, 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------