├── .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 | [](http://npm.im/html-template-tag)
4 | [](https://github.com/AntonioVdlC/html-template-tag/issues)
5 | [](http://npm.im/html-template-tag)
6 | [](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 |
42 | ${names.map((name) => html` - Hello, ${name}!
`)}
43 |
44 | `;
45 | // "- Hello, Antonio!
- Hello, Megan!
- Hello, /><script>alert('xss')</script>!
"
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 |
73 | ${names.map((name) => html` - Hello, ${name}!
`)}
74 |
75 | `;
76 |
77 | var string = template(data);
78 | /*
79 | "
80 |
81 | - Hello, Antonio!
82 | - Hello, Megan!
83 |
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`
`
29 | * =>
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 |
--------------------------------------------------------------------------------