├── .eslintrc.json ├── .gitignore ├── .gitmodules ├── .jsbeautifyrc ├── .npmignore ├── .travis.yml ├── HISTORY.md ├── LICENSE ├── README.md ├── bench └── index.js ├── custom-els.md ├── index.js ├── lib ├── compile.js ├── custom.js ├── escape.js ├── getSnippet.js ├── parse.js ├── prepareOptions.js ├── reduce.js └── sourceBuilder.js ├── package-lock.json ├── package.json └── test ├── compile.js ├── createCode.js ├── custom.js ├── escape.js ├── parse.js ├── reduce.js └── strict.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "globals": {}, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "no-constant-condition": ["error", { 10 | "checkLoops": false 11 | }], 12 | "no-console": ["error", { 13 | "allow": ["warn", "error"] 14 | }], 15 | "array-callback-return": ["error", { 16 | "allowImplicit": true 17 | }], 18 | "eqeqeq": "error", 19 | "no-implicit-coercion": "error", 20 | "no-self-compare": "error", 21 | "no-throw-literal": "error", 22 | "no-useless-return": "error", 23 | "dot-location": ["error", "property"], 24 | "no-use-before-define": ["error", { 25 | "functions": false 26 | }], 27 | "callback-return": ["error", ["callback", "cb", "next", "done", "success"]], 28 | "handle-callback-err": "error", 29 | "no-buffer-constructor": "error", 30 | "no-else-return": "warn", 31 | "camelcase": "error", 32 | "comma-dangle": ["error", "never"], 33 | "func-style": ["error", "declaration"], 34 | "function-paren-newline": ["error", "consistent"], 35 | "max-depth": "error", 36 | "max-len": ["error", { 37 | "code": 120, 38 | "ignoreStrings": true, 39 | "ignoreRegExpLiterals": true, 40 | "ignoreComments": true, 41 | "ignoreTemplateLiterals": true 42 | }], 43 | "new-cap": "error", 44 | "no-bitwise": "error", 45 | "no-lonely-if": "error", 46 | "no-mixed-operators": ["error", { 47 | "groups": [ 48 | ["&", "|", "^", "~", "<<", ">>", ">>>"], 49 | ["==", "!=", "===", "!==", ">", ">=", "<", "<="], 50 | ["&&", "||"], 51 | ["in", "instanceof"] 52 | ] 53 | }], 54 | "no-trailing-spaces": "error", 55 | "no-unneeded-ternary": "error", 56 | "operator-assignment": "error", 57 | "operator-linebreak": ["error", "after"], 58 | "quotes": ["error", "single"], 59 | "semi": ["error", "never"], 60 | "spaced-comment": "error", 61 | "arrow-body-style": "error", 62 | "arrow-parens": ["error", "as-needed"], 63 | "object-shorthand": "error", 64 | "prefer-arrow-callback": "error", 65 | "no-var": "error", 66 | // Additional rules that caused no harm 67 | "for-direction": "error", 68 | "getter-return": "error", 69 | "no-await-in-loop": "error", 70 | "no-template-curly-in-string": "error", 71 | "accessor-pairs": "error", 72 | "block-scoped-var": "error", 73 | "curly": "error", 74 | "dot-notation": "error", 75 | "no-alert": "error", 76 | "no-caller": "error", 77 | "no-div-regex": "error", 78 | "no-eq-null": "error", 79 | "no-eval": "error", 80 | "no-extend-native": "error", 81 | "no-extra-bind": "error", 82 | "no-extra-label": "error", 83 | "no-floating-decimal": "error", 84 | "no-implicit-globals": "error", 85 | "no-implied-eval": "error", 86 | "no-iterator": "error", 87 | "no-labels": "error", 88 | "no-lone-blocks": "error", 89 | "no-loop-func": "error", 90 | "no-multi-spaces": "error", 91 | "no-multi-str": "error", 92 | "no-new": "error", 93 | "no-new-func": "error", 94 | "no-new-wrappers": "error", 95 | "no-octal-escape": "error", 96 | "no-proto": "error", 97 | "no-restricted-properties": "error", 98 | "no-return-await": "error", 99 | "no-script-url": "error", 100 | "no-sequences": "error", 101 | "no-unmodified-loop-condition": "error", 102 | "no-unused-expressions": "error", 103 | "no-useless-call": "error", 104 | "no-useless-concat": "error", 105 | "no-void": "error", 106 | "no-with": "error", 107 | "prefer-promise-reject-errors": "error", 108 | "radix": "error", 109 | "require-await": "error", 110 | "wrap-iife": "error", 111 | "yoda": "error", 112 | "strict": "error", 113 | "no-catch-shadow": "error", 114 | "no-label-var": "error", 115 | "no-restricted-globals": "error", 116 | "no-shadow-restricted-names": "error", 117 | "no-undef-init": "error", 118 | "no-unused-vars": "error", 119 | "no-new-require": "error", 120 | "no-path-concat": "error", 121 | "no-process-env": "error", 122 | "array-bracket-spacing": "error", 123 | "block-spacing": "error", 124 | "brace-style": "error", 125 | "comma-spacing": "error", 126 | "comma-style": "error", 127 | "computed-property-spacing": "error", 128 | "func-call-spacing": "error", 129 | "func-name-matching": "error", 130 | "key-spacing": "error", 131 | "keyword-spacing": "error", 132 | "lines-between-class-members": "error", 133 | "max-nested-callbacks": "error", 134 | "max-statements-per-line": "error", 135 | "no-array-constructor": "error", 136 | "no-multiple-empty-lines": "error", 137 | "no-new-object": "error", 138 | "no-whitespace-before-property": "error", 139 | "nonblock-statement-body-position": "error", 140 | "object-curly-spacing": "error", 141 | "object-property-newline": "error", 142 | "padding-line-between-statements": "error", 143 | "semi-spacing": "error", 144 | "semi-style": "error", 145 | "space-before-blocks": "error", 146 | "space-in-parens": "error", 147 | "space-unary-ops": "error", 148 | "switch-colon-spacing": "error", 149 | "unicode-bom": "error", 150 | "arrow-spacing": "error", 151 | "generator-star-spacing": "error", 152 | "no-duplicate-imports": "error", 153 | "no-restricted-imports": "error", 154 | "no-useless-computed-key": "error", 155 | "no-useless-constructor": "error", 156 | "no-useless-rename": "error", 157 | "prefer-numeric-literals": "error", 158 | "prefer-rest-params": "error", 159 | "prefer-spread": "error", 160 | "rest-spread-spacing": "error", 161 | "sort-imports": "error", 162 | "symbol-description": "error", 163 | "template-curly-spacing": "error", 164 | "yield-star-spacing": "error" 165 | } 166 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | *.log -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "jaguarjs-jsdoc"] 2 | path = jaguarjs-jsdoc 3 | url = https://github.com/davidshimjs/jaguarjs-jsdoc.git 4 | -------------------------------------------------------------------------------- /.jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "js": { 3 | "indent_with_tabs": true, 4 | "eol": "\n", 5 | "end_with_newline": false, 6 | "indent_level": 0, 7 | "preserve_newlines": true, 8 | "max_preserve_newlines": 2, 9 | "space_in_paren": false, 10 | "space_in_empty_paren": false, 11 | "jslint_happy": false, 12 | "space_after_anon_function": true, 13 | "brace_style": "collapse", 14 | "break_chained_methods": false, 15 | "keep_array_indentation": false, 16 | "unescape_strings": false, 17 | "wrap_line_length": 0, 18 | "e4x": false, 19 | "comma_first": false, 20 | "operator_position": "before-newline" 21 | } 22 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bench 2 | test -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "8" 5 | - "node" -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # 5.1.5 2 | * Fixed: compiler emitted wrong JS code when dealing with placeholders or boolean attributes 3 | 4 | # 5.1.4 5 | * Fixed: missing \n 6 | 7 | # 5.1.3 8 | * Fixed: fix weird source map when an EJS tag spans multiple lines 9 | 10 | # 5.1.2 11 | * Fixed: `stricMode` not working for `compile.standAlone` 12 | 13 | # 5.1.1 14 | * Fixed: bug with empty EJS eval tags (`<% %>`) 15 | * Fixed: bug with text after EJS tag inside custom tag (`<%= a %> text`) 16 | 17 | # 5.1.0 18 | * Added: `sourceMap` option to create source maps 19 | 20 | # 5.0.0 21 | 22 | ## Breaking changes 23 | * Removed: support for node 4 24 | * Changed: from `reduce(tokens[, compileDebug])` to `reduce(tokens[, options])` 25 | * Changed: use strict mode and do not wrap code in `with(locals)` by default. 26 | 27 | The old version (v4) compiled templates to sloppy JS mode and used the long-deprecated `with()` structure. 28 | The new (v5) version changes that, but allows one to opt-out. 29 | 30 | We used `with(locals)` to allow one to write `<%= x %>` instead of `<%= locals.x %>`, but employing `with()` forced 31 | the lib to compile to sloppy mode since this construct isn't allowed in strict mode. 32 | 33 | In this new version, we revisited that decision and prefered to drop `with` in favor of strict mode. 34 | To ease transition, you can opt-out and keep using the old behavior with the option `strictMode: false`. 35 | On the other hand, if don't want to keep writing `locals.` all the time, you can list which variables should be made 36 | available with the option `vars: ['someVar', 'anotherOne']`. See the examples below 37 | 38 | ```js 39 | /** 40 | * Old (v4) 41 | */ 42 | 43 | // Variables could be accessed directly, unless there were absent in the locals parameter 44 | ejs.render('<%= a %>', {a: 2}) // '2' 45 | ejs.render('<%= b %>', {a: 2}) // ReferenceError: b is not defined 46 | 47 | // In sloppy mode, weird things happen, like leaking to global context 48 | ejs.render('<% x = 17 %>') // '' 49 | x // 17 50 | 51 | /** 52 | * New (v5) 53 | */ 54 | 55 | // Direct access does not work out of the box 56 | ejs.render('<%= a %>', {a: 2}) // ReferenceError: a is not defined 57 | 58 | // You have to be explicit. Either use the locals object or list variables to be made available 59 | ejs.render('<%= locals.a %>', {a: 2}) // '2' 60 | ejs.render('<%= a %>', {a: 2}, {vars: ['a']}) // '2' 61 | ejs.render('<%= b %>', {a: 2}, {vars: ['b']}) // '' 62 | 63 | // Code is executed in strict mode 64 | ejs.render('<% x = 17 %>') // ReferenceError: x is not defined 65 | 66 | // If you REALLY want to use old behavior 67 | ejs.render('<%= a %>', {a: 2}, {strictMode: false}) // '2' 68 | ``` 69 | 70 | ## Other changes 71 | * Added: option `strictMode` (defaults to `true`) 72 | * Added: option `vars` (defaults to `[]`) 73 | 74 | # 4.0.3 75 | * Fixed: bug in minifier with spaces around some EJS tags 76 | 77 | # 4.0.2 78 | * Fixed: compiling custom elements inside custom elements with `compileDebug` set to `false` would crash on runtime. The fix on 4.0.1 did not covered the recursive case. 79 | 80 | # 4.0.1 81 | * Fixed: compiling custom elements with `compileDebug` set to `false` would crash on runtime 82 | 83 | # 4.0.0 84 | 85 | ## Breaking changes 86 | * Removed: `compile.both()`, since it was not as useful as it has appeared at first and it would make the other improvents in this release harder to implement. 87 | * Removed: compile `debug` option, since it was not useful to be present in the public API 88 | 89 | ## Other changes 90 | * Added: compile option `compileDebug` (defaults to `true`) to indicate whether to add extended context to exceptions 91 | 92 | # 3.1.1 93 | * Fixed: white-space-only content for custom element is considered empty and the default placeholder is used 94 | 95 | # 3.1.0 96 | * Added: `compile.both(source[, option])` 97 | 98 | # 3.0.1 99 | * Fixed: npm.js does not render tabs on README correctly 100 | 101 | # 3.0.0 102 | 103 | ## Breaking Changes 104 | * Changed: EJS eval tags `<% %>` are no longer allowed in attribute values, for safety and simplicity, use escaped tags `<%= %>` 105 | * Removed: `standAlone` option to `compile()`. Use new function `compile.standAlone()` for similar effect 106 | * Added: `compile.standAlone()`. It returns the JS render function body as a string. The string can be trasmitted to a client and then the render function reconstructed with `new Function('locals, customRender', code)` 107 | 108 | # 2.1.0 109 | * Added: exposed `getSnippet()` 110 | 111 | # 2.0.1 112 | * Fixed: detect and throw syntax error on repeated attributes 113 | * Fixed: boolean and case attributes handling in custom tags 114 | * Fixed: show line numbers in render-time errors and fix line mapping for custom tags 115 | 116 | # 2.0.0 117 | * Changed: `` is no longer a void element, its content indicates the default value if no content for it is provided 118 | 119 | # 1.2.0 120 | * Added: support for custom elements 121 | 122 | # 1.1.0 123 | * Added: parse HTML as tree of elements 124 | * Added: check element tree is well balanced (no implicit end-tags) 125 | * Added: `transformer` option to extend semantics 126 | * Fixed: whitespace collapsing inside pre, script, style and textarea tags 127 | * Fixed: parsing of script and style tags 128 | 129 | # 1.0.0 130 | * Started -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Guilherme Souza 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EJS HTML 2 | [![Build Status](https://travis-ci.org/sitegui/ejs-html.svg?branch=master)](https://travis-ci.org/sitegui/ejs-html) 3 | [![Inline docs](https://inch-ci.org/github/sitegui/ejs-html.svg?branch=master)](https://inch-ci.org/github/sitegui/ejs-html) 4 | [![Dependency Status](https://david-dm.org/sitegui/ejs-html.svg)](https://david-dm.org/sitegui/ejs-html) 5 | 6 | Embedded JavaScript HTML templates. An implementation of EJS focused on run-time performance, HTML syntax checking, minified HTML output and custom HTML elements. 7 | 8 | ## Usage 9 | `npm install ejs-html --save` 10 | 11 | ```js 12 | let ejs = require('ejs-html') 13 | 14 | let html = ejs.render('', { 15 | disabled: false, 16 | value: 'hi you' 17 | }, { 18 | vars: ['disabled', 'value'] 19 | }) 20 | 21 | // html = '' 22 | ``` 23 | 24 | ## Why another EJS implementation? 25 | This module is inspired by [EJS](http://ejs.co/), and is a subset of its syntax, focused on giving HTML first-class support. That is, not all EJS are valid EJS-HTML. Most features listed bellow are possible only with an HTML-aware parser. 26 | 27 | Check their excellent site for EJS-specific docs and tutorials. 28 | 29 | Strictly speaking, this *is not* even EJS (details bellow). 30 | 31 | ## Breaking changes in v5 32 | Old versions compiled to sloppy mode and used the `with(locals)` block by default. 33 | That allowed one to write `<%= a %>` instead of `<%= locals.a %>` but had more unwanted consequences. 34 | Read more about what changed and how to opt-out from the change in [HISTORY.md](https://github.com/sitegui/ejs-html/blob/master/HISTORY.md). 35 | 36 | ## Features 37 | 38 | ### Compile-time HTML minification 39 | The template source is parsed and minified on compile time, so there is no impact on render-time. The minification applies these rules: 40 | 41 | * Collapse text whitespace: `Hello\n\t you` is transformed to `Hello\nyou` 42 | * Remove attribute quotes: `
` → `
` 43 | * Normalize attributes spaces: `` → `` 44 | * Normalize class spaces: `
` → `
` 45 | * Simplify boolean attributes: `` → `` 46 | * Remove self-close slash: `
` → `
` 47 | 48 | ### Render-time error mapping 49 | Errors during render-time are mapped back to their original source location (that is, we keep an internal source map) 50 | 51 | ```js 52 | ejs.render(``, { 59 | options: [null] 60 | }) 61 | ``` 62 | 63 | ``` 64 | TypeError: ejs:3 65 | 1 |
17 | 18 | 19 | 20 | 25 | 26 | 27 | 28 | ` 29 | 30 | source = source + source + source + source + source 31 | 32 | let renderEjs = time('compile-ejs', () => ejs.compile(source)) 33 | 34 | let renderEjsHtml = time('compile-ejs-html', () => ejsHtml.compile(source, { 35 | collapseText: true, 36 | collapseAttribute: true, 37 | boolAttribute: true, 38 | standAlone: true 39 | })) 40 | 41 | let data = { 42 | lang: 'en-us', 43 | title: 'My <<<<<<<<<<<< title >>>>>>>>>>>', 44 | action: '/send-it', 45 | value: 'initial value', 46 | disabled: false, 47 | selectedIndex: 2, 48 | items: [{ 49 | value: '2', 50 | text: 'two' 51 | }, { 52 | value: '3', 53 | text: 'three' 54 | }, { 55 | value: '5', 56 | text: 'five' 57 | }, { 58 | value: '7', 59 | text: 'seven' 60 | }, { 61 | value: '11', 62 | text: 'eleven' 63 | }, { 64 | value: '13', 65 | text: 'thirdteen' 66 | }, { 67 | value: '17', 68 | text: 'seventeen' 69 | }] 70 | } 71 | 72 | time('render-ejs', () => renderEjs(data)) 73 | 74 | time('render-ejs-html', () => renderEjsHtml(data)) 75 | 76 | function time(name, fn) { 77 | for (let i = 0; i < 1e3; i++) { 78 | fn() 79 | } 80 | let start = Date.now(), 81 | n = 1e3, 82 | result 83 | for (let i = 0; i < n; i++) { 84 | result = fn() 85 | } 86 | let dt = (Date.now() - start) / n 87 | // eslint-disable-next-line no-console 88 | console.log(`${name}: ${dt.toFixed(2)}ms`) 89 | return result 90 | } -------------------------------------------------------------------------------- /custom-els.md: -------------------------------------------------------------------------------- 1 | # Custom Elements 2 | 3 | ## Introduction 4 | Custom HTML elements is a greater replacement for `include`. It delegates a portion of the rendering to another EJS template. 5 | 6 | The basic example bellow defines a custom text input element, that has its input inside an label element and has a text title in front of it: 7 | ```html 8 | 11 | ``` 12 | 13 | Another EJS template file may "instantiate" this element: 14 | ```html 15 | 16 | ``` 17 | 18 | The final rendering result is shown bellow: 19 | ```html 20 | 23 | ``` 24 | 25 | ## Concepts 26 | As of this writting, the W3C is currently working in custom elements for the Web, under the [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) umbrella. But we are *not* talking about that here, this is a completely different beast (inspired by the rising standard, but yet not the same thing). EJS-HTML custom elements are resolved at render time, before the browser get to the HTML. 27 | 28 | Any element that have a dash (`-`) on its name will be treated as custom (this follows the W3C standard). At compile time, they will be identified and compiled to a `renderCustom` call. At rendering time, the `renderCustom` will be called in order to render the custom element and return the HTML result. So it works conceptually like a super-powered include, because it accepts dynamic attributes and complex HTML content. 29 | 30 | ## Attributes 31 | Attributes in the custom element's open tag will be passed as the `locals` for it. 32 | 33 | The attribute name will be transformed from dashed-separated to camel-case notation, for example, the attribute `'my-own-attr'` will be passed as `'myOwnAttr'` local data. The rule is: any dash (U+002D) followed by an ASCII lowercase letter a to z will be removed and the letter will be transformed into its uppercase counterpart. 34 | 35 | There are three distinct notations for attributes, depicted bellow. 36 | 37 | * boolean/true: `` will produce the following locals object: `{avoidGoats: true}`, much like native HTML boolean attributes. `false` should be represented by its absence. 38 | * string: `` will produce: `{avoid: 'goats', keep: 'all ' + animal + 's'}` 39 | * JavaScript value: `` will produce: `{avoid: ['goats', 'more goats']}`. This allows complex data to be passed as part of the `locals`, not only strings. Note that the syntax is `attr="<%= ... %>"`, with the quotes right next to the EJS escaped tag. Any character between them (including spaces), would concatenate them and result in a string. 40 | 41 | ## Content Placeholder 42 | The `` tag in a custom element definition will be replaced by the content inside the custom element (`eh` stands for `ejs-html`). 43 | 44 | The example bellow shows a basic usage. The declaration and usage are represented in the same code block for brevity, but they are usually written separately. 45 | ```html 46 | 47 | 48 | 49 | 50 | Hi you 51 | 52 | 53 | 54 | ``` 55 | 56 | Use content placeholders to pass arbitrary HTML content and attributes to anything else. 57 | 58 | ## Multiple Content Areas 59 | Sometimes it is useful to have multiple placeholder areas. If this is the case, you may name each one with `` in the definition and mark each type with `` on usage. 60 | 61 | Example: 62 | ```html 63 | 64 |

65 |

66 | 67 | 68 | 69 | Title 70 | Body 71 | 72 | 73 | 74 |

Title

75 |

Body

76 | ``` 77 | 78 | Note that an empty-named content markup (``) is implied for any content not inside a `eh-content` tag. In the example above, `Body` is treated as if it was written as `Body` 79 | 80 | ## Default Placeholder Content 81 | A `eh-placeholder` element will be replaced by the content provided for it. If no content is given, you can provide a fallback. 82 | 83 | One practical application of this feature is to allow both simple and complex content, from both attribute and HTML content. Like this: 84 | ```html 85 | 86 |

87 | 88 | <%= locals.title %> 89 | 90 |

91 |

92 | 93 | 94 | One 95 | 96 | 97 | Complex Title 98 | 99 | Two 100 | 101 | 102 | 103 |

Simple Title

104 |

One

105 |

Complex Title

106 |

Two

107 | ``` 108 | 109 | Note how the default content for the title is a read from the `title` attribute, but if a HTML content for it is provided, it's used instead. 110 | 111 | ## Divergence From W3C's Web Components 112 | In the current spec, the W3C declares a `` tag to act as ejs-html's ``. The spec is not followed by this lib because (a) its mechanism based on CSS selectors to solve multiple content areas is too complex (b) its usage is hard to optimize on compile time (c) there is no support for default content. 113 | 114 | ## The CustomRender Callback 115 | Currently, this lib does not attempt to detect which EJS template to use to render a given custom element. You must implement that yourself and provide when rendering each template. For example, if your custom element definitions are in a folder, you are responsible to handle the routing. 116 | 117 | A full example bellow, for the given folder structure: 118 | ``` 119 | -- elements 120 | | 121 | +- my-input.ejs 122 | +- my-dialog.ejs 123 | +- my-header.ejs 124 | -- views 125 | | 126 | +- home.ejs 127 | ``` 128 | 129 | And the following content for `home.ejs`: 130 | ```html 131 | 132 | 133 |
134 | 135 |
136 |
137 | ``` 138 | 139 | To render the home page: 140 | ```js 141 | let ejs = require('ejs-html'), 142 | fs = require('fs'), 143 | cache = new Map 144 | 145 | // Simple caching logic 146 | function compile(path) { 147 | if (!cache.has(path)) { 148 | cache.set(path, ejs.compile(fs.readFileSync(path, 'utf8'), { 149 | filename: path 150 | })) 151 | } 152 | return cache.get(path) 153 | } 154 | 155 | compile('views/home.ejs')({}, function renderCustom(name, locals) { 156 | // We are responsible to translate the element name (like 'my-header') to file path 157 | // Note that `renderCustom` is passed as argument again, enabling custom elements to 158 | // also use others 159 | return compile('elements/' + name + '.ejs')(locals, renderCustom) 160 | }) 161 | ``` 162 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports.compile = require('./lib/compile') 4 | 5 | /** 6 | * @param {string} source 7 | * @param {Object} [locals={}] 8 | * @param {Object} [options] - see {@link prepareOptions} 9 | * @returns {string} 10 | */ 11 | module.exports.render = function (source, locals, options) { 12 | return module.exports.compile(source, options)(locals) 13 | } 14 | 15 | // Utils 16 | module.exports.parse = require('./lib/parse') 17 | module.exports.reduce = require('./lib/reduce') 18 | module.exports.escape = require('./lib/escape') 19 | module.exports.getSnippet = require('./lib/getSnippet') 20 | 21 | module.exports._prepareOptions = require('./lib/prepareOptions') -------------------------------------------------------------------------------- /lib/compile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let escape = require('./escape'), 4 | parse = require('./parse'), 5 | getSnippet = require('./getSnippet'), 6 | prepareOptions = require('./prepareOptions'), 7 | sourceBuilder = require('./sourceBuilder'), 8 | reduce 9 | 10 | /** 11 | * A function that may transform the parsed tree before the compilation continues. 12 | * This should return a new array of tokens or `undefined` to use the same (in case 13 | * of in-place changes) 14 | * @callback TransformerFn 15 | * @param {Array} tokens 16 | * @returns {?Array} 17 | */ 18 | 19 | /** 20 | * @callback Render 21 | * @param {Object} locals 22 | * @param {CustomRender} renderCustom 23 | * @returns {string} 24 | */ 25 | 26 | /** 27 | * @callback CustomRender 28 | * @param {string} elementName 29 | * @param {Object} locals 30 | * @returns {string} 31 | */ 32 | 33 | /** 34 | * @param {string} source 35 | * @param {Object} [options] - see {@link prepareOptions} 36 | * @returns {Render} 37 | */ 38 | module.exports = function (source, options) { 39 | options = prepareOptions(options) 40 | 41 | let builder = prepareInternalJSCode(source, options) 42 | 43 | if (options.strictMode) { 44 | builder.prepend('"use strict";') 45 | } 46 | 47 | let { 48 | code, 49 | map, 50 | mapWithCode 51 | } = builder.build(source) 52 | 53 | let internalRender 54 | try { 55 | // eslint-disable-next-line no-new-func 56 | internalRender = new Function('locals, renderCustom, __e, __l', code) 57 | } catch (e) { 58 | e.message += ` (in ${options.filename}, while compiling ejs)` 59 | throw e 60 | } 61 | 62 | let fn 63 | if (!options.compileDebug) { 64 | // No special exception handling 65 | fn = function (locals, renderCustom) { 66 | return internalRender(locals, renderCustom, escape.html) 67 | } 68 | } else { 69 | fn = function (locals, renderCustom) { 70 | let line = { 71 | s: 0, 72 | e: 0 73 | } 74 | try { 75 | return internalRender(locals, renderCustom, escape.html, line) 76 | } catch (err) { 77 | let snippet = getSnippet(source, line.s, line.e) 78 | err.path = options.filename 79 | err.message = `${options.filename}:${line.s}\n${snippet}\n\n${err.message}` 80 | throw err 81 | } 82 | } 83 | } 84 | 85 | if (options.sourceMap) { 86 | fn.code = code 87 | fn.map = map 88 | fn.mapWithCode = mapWithCode 89 | } 90 | 91 | return fn 92 | } 93 | 94 | /** 95 | * Much like {@link compile}, but returns a stand-alone JS source code, 96 | * that can be exported to another JS VM. When there, turn this into a function 97 | * with: render = new Function('locals, renderCustom', returnedCode) 98 | * @param {string} source 99 | * @param {Object} [options] - see {@link prepareOptions} 100 | * @returns {string} 101 | */ 102 | module.exports.standAlone = function (source, options) { 103 | return module.exports.standAloneAsObject(source, options).code 104 | } 105 | 106 | /** 107 | * Much like {@link compile}, but returns a stand-alone JS source code, 108 | * that can be exported to another JS VM. When there, turn this into a function 109 | * with: render = new Function('locals, renderCustom', returnedCode) 110 | * @param {string} source 111 | * @param {Object} [options] - see {@link prepareOptions} 112 | * @returns {{code: string, map: ?string, mapWithCode: ?string}} 113 | */ 114 | module.exports.standAloneAsObject = function (source, options) { 115 | options = prepareOptions(options) 116 | 117 | let subBuilder = prepareInternalJSCode(source, options), 118 | builder = sourceBuilder(options) 119 | 120 | if (options.strictMode) { 121 | builder.add('"use strict";') 122 | } 123 | 124 | if (!options.compileDebug) { 125 | // No special exception handling 126 | builder.add(`${escape.html.standAloneCode}\n`) 127 | builder.addBuilder(subBuilder) 128 | } else { 129 | builder.add(`${escape.html.standAloneCode}\n`) 130 | builder.add(`let __gS=${getSnippet.min.toString()},__l={s:0,e:0},__s="${escape.js(source)}";`) 131 | builder.add('try {') 132 | builder.addBuilder(subBuilder) 133 | builder.add('}catch(e){') 134 | builder.add('let s=__gS(__s,__l.s,__l.e);') 135 | builder.add(`e.path="${escape.js(options.filename)}";`) 136 | builder.add(`e.message="${escape.js(options.filename)}:"+__l.s+"\\n"+s+"\\n\\n"+e.message;`) 137 | builder.add('throw e;') 138 | builder.add('}') 139 | } 140 | 141 | return builder.build(source) 142 | } 143 | 144 | /** 145 | * Common logic for `compile` and `compile.standAlone` 146 | * @private 147 | * @param {string} source 148 | * @param {Object} options - already prepared 149 | * @returns {SourceBuilder} 150 | */ 151 | function prepareInternalJSCode(source, options) { 152 | // Parse 153 | let tokens = parse(source) 154 | 155 | // Transform 156 | if (options.transformer) { 157 | tokens = options.transformer(tokens) || tokens 158 | } 159 | 160 | let reducedTokens = reduce(tokens, options) 161 | 162 | return createCode(reducedTokens, options, false) 163 | } 164 | 165 | reduce = require('./reduce') 166 | 167 | /** 168 | * Create the JS for the body of a function that will render the HTML content 169 | * @param {Array} tokens 170 | * @param {Object} options - already prepared 171 | * @param {boolean} asInnerExpression - whether to return code to be used inside a parent createCode() context 172 | * @returns {SourceBuilder} 173 | */ 174 | function createCode(tokens, options, asInnerExpression) { 175 | let builder = sourceBuilder(options) 176 | 177 | if (!tokens.length || (tokens.length === 1 && typeof tokens[0] === 'string')) { 178 | // Special case for static string 179 | builder.add(`${asInnerExpression ? '' : 'return'}"${escape.js(tokens[0] || '')}"`) 180 | return builder 181 | } 182 | 183 | let hasStatements = tokens.some(t => typeof t === 'object' && t.type === 'ejs-eval') 184 | 185 | // Current print position for an expression, possible values for hasStatements: 186 | // 187 | // let __o = + ; 188 | // // some code 189 | // __o += + ; 190 | // 191 | // Possible values for !hasStatements: 192 | // return + ; 193 | let state = 'very-first' 194 | 195 | // Prepare header 196 | if (!asInnerExpression) { 197 | builder.add('locals=locals||{};let __c=locals.__contents||{};') 198 | if (!options.strictMode) { 199 | builder.add('with(locals){') 200 | } 201 | if (options.vars.length) { 202 | builder.add('let ' + 203 | options.vars.map(each => `${each}=locals.${each}`).join(',') + ';') 204 | } 205 | } 206 | if (hasStatements) { 207 | // We'll need a temporary variable to hold the output generated so far 208 | if (asInnerExpression) { 209 | // Wrap in an immediate-invocated function 210 | builder.add('(function(){') 211 | } 212 | builder.add('let __o=') 213 | } else if (!asInnerExpression) { 214 | builder.add('return ') 215 | } 216 | 217 | // Prepare body 218 | for (let i = 0, len = tokens.length; i < len; i++) { 219 | let token = tokens[i] 220 | 221 | if (typeof token === 'string') { 222 | appendExpression(`"${escape.js(token)}"`, null, null, true) 223 | } else if (token.type === 'ejs-eval') { 224 | appendStatement(token) 225 | } else if (token.type === 'ejs-escaped') { 226 | appendExpression('__e(', token, ')', true) 227 | } else if (token.type === 'ejs-raw') { 228 | appendExpression('(', token, ')', false) 229 | } else if (token.type === 'source-builder') { 230 | appendExpression('(', token, ')', false) 231 | } 232 | } 233 | 234 | // Prepare footer 235 | if (state === 'rest' && (!asInnerExpression || hasStatements)) { 236 | builder.add(';') 237 | } 238 | if (hasStatements) { 239 | builder.add('return __o;') 240 | if (asInnerExpression) { 241 | builder.add('})()') 242 | } 243 | } 244 | if (!asInnerExpression && !options.strictMode) { 245 | builder.add('}') // close with(locals) 246 | } 247 | return builder 248 | 249 | /** 250 | * Append an expression that contributes directly to the output 251 | * @param {?string} prefix 252 | * @param {?Token|SourceBuilder} token 253 | * @param {?string} suffix 254 | * @param {boolean} isString - whether this expression certainly evaluates to a string 255 | */ 256 | function appendExpression(prefix, token, suffix, isString) { 257 | if (state === 'very-first') { 258 | if (!isString) { 259 | builder.add('""+') 260 | } 261 | } else if (state === 'first') { 262 | builder.add('__o+=') 263 | } else { 264 | builder.add('+') 265 | } 266 | 267 | if (options.compileDebug && token) { 268 | builder.add(`(${getDebugMarker(token)},`) 269 | } 270 | if (prefix) { 271 | builder.add(prefix) 272 | } 273 | if (token) { 274 | if (token.type === 'source-builder') { 275 | builder.addBuilder(token.sourceBuilder) 276 | } else { 277 | builder.addToken(token) 278 | } 279 | } 280 | if (suffix) { 281 | builder.add(suffix) 282 | } 283 | if (options.compileDebug && token) { 284 | builder.add(')') 285 | } 286 | 287 | state = 'rest' 288 | } 289 | 290 | /** 291 | * Append statements that do not produce output directly 292 | * (This won't be called if !hasStatements) 293 | * @param {Token} token 294 | */ 295 | function appendStatement(token) { 296 | if (state === 'very-first') { 297 | builder.add('"";') 298 | } else if (state === 'rest') { 299 | builder.add(';') 300 | } 301 | 302 | if (options.compileDebug) { 303 | builder.add(`${getDebugMarker(token)};`) 304 | } 305 | if (token.type === 'source-builder') { 306 | builder.addBuilder(token.sourceBuilder) 307 | } else { 308 | builder.addToken(token) 309 | } 310 | builder.add('\n') 311 | 312 | state = 'first' 313 | } 314 | } 315 | 316 | /** 317 | * @private 318 | */ 319 | module.exports._createCode = createCode 320 | 321 | /** 322 | * @param {Token} token 323 | * @returns {string} - a JS expression 324 | * @private 325 | */ 326 | function getDebugMarker(token) { 327 | let start = token.start.line, 328 | end = token.end.line 329 | if (start === end) { 330 | return `__l.s=__l.e=${end}` 331 | } 332 | return `__l.s=${start},__l.e=${end}` 333 | } 334 | 335 | /** 336 | * @private 337 | */ 338 | module.exports._getDebugMarker = getDebugMarker -------------------------------------------------------------------------------- /lib/custom.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let jsEscape = require('./escape').js, 4 | reduce = require('./reduce'), 5 | compile = require('./compile'), 6 | sourceBuilder = require('./sourceBuilder') 7 | 8 | /** 9 | * @param {Token} element 10 | * @param {Object} options - already prepared 11 | * @returns {Token} - an ejs-raw token 12 | * @private 13 | */ 14 | module.exports.prepareContent = function (element, options) { 15 | let builder = sourceBuilder(options) 16 | 17 | // First parameter: tag name 18 | builder.add('renderCustom(') 19 | builder.add(`"${jsEscape(element.name)}",{`) 20 | 21 | // Second argument: locals 22 | for (let i = 0; i < element.attributes.length; i++) { 23 | let attribute = element.attributes[i] 24 | builder.add(`"${jsEscape(makeCamelCase(attribute.name))}":`) 25 | 26 | if (attribute.type === 'attribute-simple') { 27 | if (attribute.quote === '' && attribute.value === '') { 28 | // Pseudo-boolean attribute 29 | builder.add('true') 30 | } else { 31 | builder.add(`"${jsEscape(attribute.value)}"`) 32 | } 33 | } else if (attribute.type === 'attribute') { 34 | let firstPart = attribute.parts[0] 35 | 36 | if (attribute.parts.length === 1 && firstPart.type === 'ejs-escaped') { 37 | // Special case for 38 | // Pass `value` directly, without casting to string 39 | appendJSValue(firstPart) 40 | } else { 41 | appendExpressionFromParts(attribute.parts) 42 | } 43 | 44 | } 45 | builder.add(',') 46 | } 47 | 48 | builder.add('__contents:{') 49 | 50 | let contents = prepareContents(element.children), 51 | firstContent = true 52 | contents.forEach((tokens, name) => { 53 | if (!firstContent) { 54 | builder.add(',') 55 | } else { 56 | firstContent = false 57 | } 58 | let subBuilder = compile._createCode(reduce(tokens, options), options, true) 59 | builder.add(`"${jsEscape(name)}":`) 60 | builder.addBuilder(subBuilder) 61 | }) 62 | 63 | builder.add(options.compileDebug ? 64 | `}},${compile._getDebugMarker(element)})` : 65 | '}})') 66 | 67 | return { 68 | type: 'source-builder', 69 | start: element.start, 70 | end: element.end, 71 | sourceBuilder: builder 72 | } 73 | 74 | /** 75 | * @param {Array} parts 76 | */ 77 | function appendExpressionFromParts(parts) { 78 | for (let i = 0, len = parts.length; i < len; i++) { 79 | let part = parts[i] 80 | if (i) { 81 | builder.add('+') 82 | } 83 | if (part.type === 'text') { 84 | builder.add(`"${jsEscape(part.content)}"`) 85 | } else if (part.type === 'ejs-escaped') { 86 | builder.add('String(') 87 | appendJSValue(part) 88 | builder.add(')') 89 | } else if (part.type === 'ejs-eval') { 90 | throw new Error('EJS eval tags are not allowed inside attribute values in custom elements') 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * Append ejs expression, with position update 97 | * @param {Token} token - ejs-escaped token 98 | */ 99 | function appendJSValue(token) { 100 | builder.add('(') 101 | if (options.compileDebug) { 102 | builder.add(`${compile._getDebugMarker(token)},`) 103 | } 104 | builder.addToken(token) 105 | builder.add(')') 106 | } 107 | } 108 | 109 | /** 110 | * @param {Token} element 111 | * @param {Object} options - already prepared 112 | * @returns {Token} - an ejs-raw token 113 | */ 114 | module.exports.preparePlaceholder = function (element, options) { 115 | let name = getNameAttributeValue(element), 116 | escapedName = jsEscape(name), 117 | subBuilder = compile._createCode(reduce(element.children, options), options, true), 118 | builder = sourceBuilder(options) 119 | 120 | builder.add(`__c["${escapedName}"]&&/\\S/.test(__c["${escapedName}"])?__c["${escapedName}"]:`) 121 | builder.addBuilder(subBuilder) 122 | return { 123 | type: 'source-builder', 124 | start: element.start, 125 | end: element.end, 126 | sourceBuilder: builder 127 | } 128 | } 129 | 130 | /** 131 | * Split children tokens by content name 132 | * @param {Array} tokens 133 | * @returns {Map>} 134 | */ 135 | function prepareContents(tokens) { 136 | let contents = new Map 137 | 138 | for (let i = 0, len = tokens.length; i < len; i++) { 139 | let token = tokens[i] 140 | 141 | if (token.type === 'element' && token.name === 'eh-content') { 142 | // Find attribute 'name' 143 | let name = getNameAttributeValue(token), 144 | arr = getArr(name) 145 | 146 | for (let j = 0, len2 = token.children.length; j < len2; j++) { 147 | arr.push(token.children[j]) 148 | } 149 | } else { 150 | getArr('').push(token) 151 | } 152 | } 153 | 154 | /** 155 | * @param {string} name 156 | * @returns {Array} 157 | */ 158 | function getArr(name) { 159 | if (!contents.has(name)) { 160 | let arr = [] 161 | contents.set(name, arr) 162 | return arr 163 | } 164 | return contents.get(name) 165 | } 166 | 167 | return contents 168 | } 169 | 170 | /** 171 | * Return the value of the `value` attribute 172 | * @param {Token} element - must be of type 'element' 173 | * @returns {string} 174 | */ 175 | function getNameAttributeValue(element) { 176 | for (let i = 0, len = element.attributes.length; i < len; i++) { 177 | let attribute = element.attributes[i] 178 | 179 | if (attribute.name === 'name') { 180 | if (attribute.type !== 'attribute-simple') { 181 | throw new Error(`name attribute for ${element.name} tag must be a literal value`) 182 | } 183 | return attribute.value 184 | } 185 | } 186 | 187 | return '' 188 | } 189 | 190 | /** 191 | * Turn dashed notation to camel case. 192 | * Example: 'ejs-html' to 'ejsHtml' 193 | * @param {string} name 194 | * @returns {string} 195 | */ 196 | function makeCamelCase(name) { 197 | return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) 198 | } -------------------------------------------------------------------------------- /lib/escape.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let htmlCharMap = { 4 | '&': '&', 5 | '<': '<', 6 | '>': '>', 7 | '"': '"', 8 | '\'': ''' 9 | }, 10 | htmlRegex = /[&<>"']/g, 11 | jsCharMap = { 12 | '\\': '\\\\', 13 | '\n': '\\n', 14 | '\r': '\\r', 15 | '"': '\\"' 16 | }, 17 | jsRegex = /[\\\n\r"]/g 18 | 19 | /** 20 | * @param {string} [str] 21 | * @returns {string} 22 | */ 23 | module.exports.html = function html(str) { 24 | if (str === undefined || str === null) { 25 | return '' 26 | } 27 | return String(str).replace(htmlRegex, encodeHTMLChar) 28 | } 29 | 30 | /** 31 | * @type {string} 32 | */ 33 | module.exports.html.standAloneCode = 'function __e(s) {' + 34 | 'return s==null?"":String(s)' + 35 | '.replace(/&/g,"&")' + 36 | '.replace(//g,">")' + 38 | '.replace(/\'/g,"'")' + 39 | '.replace(/"/g,""")' + 40 | '}' 41 | 42 | /** 43 | * Escape as to make safe to put inside double quotes: x = "..." 44 | * @param {string} [str] 45 | * @returns {string} 46 | */ 47 | module.exports.js = function js(str) { 48 | if (str === undefined || str === null) { 49 | return '' 50 | } 51 | return String(str).replace(jsRegex, encodeJSChar) 52 | } 53 | 54 | function encodeHTMLChar(c) { 55 | return htmlCharMap[c] 56 | } 57 | 58 | function encodeJSChar(c) { 59 | return jsCharMap[c] 60 | } -------------------------------------------------------------------------------- /lib/getSnippet.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* eslint prefer-arrow-callback: off */ 3 | 4 | /** 5 | * Extract the code snippet in the given region 6 | * @param {string} source - original source 7 | * @param {number} lineStart 8 | * @param {number} lineEnd 9 | * @returns {string} 10 | */ 11 | module.exports = function (source, lineStart, lineEnd) { 12 | let fromLine = Math.max(0, lineStart - 3) 13 | return source.split('\n').slice(fromLine, lineEnd + 2).map(function (str, i) { 14 | let lineNum = i + 1 + fromLine 15 | return ' ' + lineNum + ' ' + (lineNum >= lineStart && lineNum <= lineEnd ? '>>' : ' ') + ' | ' + str 16 | }).join('\n') 17 | } 18 | 19 | /** 20 | * Like getSnippet(), but with minimized code 21 | */ 22 | module.exports.min = function (a, b, c) { 23 | let d = Math.max(0, b - 3) 24 | return a.split('\n').slice(d, c + 2).map(function (e, i) { 25 | let f = i + 1 + d 26 | return ' ' + f + ' ' + (f >= b && f <= c ? '>>' : ' ') + ' | ' + e 27 | }).join('\n') 28 | } -------------------------------------------------------------------------------- /lib/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Match the start of the next non-text token 4 | let nonTextStartRegex = /<(!DOCTYPE |!--|%=|%-|%(?!%)|\/|(?!%%))/ig, 5 | // Match the start of the next non-text token when inside a special element 6 | // (an special element may contain anything, up to a matching closing tag 7 | nonSpecialTextStartRegex = { 8 | script: /<(%=|%-|%(?!%)|\/(?=script\s*>))/ig, 9 | style: /<(%=|%-|%(?!%)|\/(?=style\s*>))/ig 10 | }, 11 | // Match the end of the current ejs-* token 12 | ejsEndRegex = /\s*%>/g, 13 | // Match the end of the current doctype or close-tag 14 | tagEndRegex = />/g, 15 | // Match the end of the current comment tag 16 | commentEndRegex = /-->/g, 17 | // Match the closing tag 18 | tagCloseRegex = /^([a-z][^\s/>]*)\s*>/i, 19 | // Valid tag names 20 | tagNameRegex = /^[a-z][^\s/>]*/i, 21 | // Find the next start of attribute, ejs or tag end 22 | tagOpenContentStartRegex = /^\s*(<%=|<%-|<%(?!%)|>|\/>|[^\s/>"'<=]+)/, 23 | // Match static attribute values, or the start of a dynamic quoted one 24 | // This can generate false negatives, for example, in: 25 | // '="a' even tough the value is fixed (no ejs), will be read as a dynamic one 26 | // But this will be fixed by inspecting the we only got one text part 27 | attributeValueRegex = /^\s*=\s*("|'|[^\s>"'<=`]+|"[^"<]*"|'[^'<]*')/, 28 | // Find the next start of ejs in attribute value or its end 29 | nonTextValueStartDoubleRegex = /<%=|<%-|<%(?!%)|"/g, 30 | nonTextValueStartSingleRegex = /<%=|<%-|<%(?!%)|'/g, 31 | // Known boolean attributes 32 | booleanAttributeRegex = /^(allowfullscreen|async|autofocus|autoplay|checked|compact|controls|declare|default|defaultchecked|defaultmuted|defaultselected|defer|disabled|enabled|formnovalidate|hidden|indeterminate|inert|ismap|itemscope|loop|multiple|muted|nohref|noresize|noshade|novalidate|nowrap|open|pauseonexit|readonly|required|reversed|scoped|seamless|selected|sortable|spellcheck|truespeed|typemustmatch|visible)$/, 33 | // Known void elements (elements that must have no content) 34 | voidElementsRegex = /^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/, 35 | assert = require('assert'), 36 | getSnippet = require('./getSnippet') 37 | 38 | /** 39 | * @typedef {Object} Token 40 | * @property {string} type - one of: text, ejs-eval, ejs-escaped, ejs-raw, comment, doctype, element, source-builder 41 | * @property {SourcePoint} start - inclusive 42 | * @property {SourcePoint} end - non inclusive 43 | * @property {?string} content - present for types: text, ejs-eval, ejs-escaped, ejs-raw, comment, doctype 44 | * @property {?string} name - present for type: element 45 | * @property {?boolean} isVoid - whether this is a void element, present for type: element 46 | * @property {?Array} attributes - present for type: element 47 | * @property {?Array} children - present for type: element 48 | * @property {?SourceBuilder} sourceBuilder - present for type source-builder 49 | */ 50 | 51 | /** 52 | * @typedef {Object} SourcePoint 53 | * @property {number} pos - zero-indexed position in the original source 54 | * @property {number} line - one-indexed 55 | * @property {number} column - one-indexed 56 | */ 57 | 58 | /** 59 | * @typedef {Object} Attribute 60 | * @property {string} type - one of: attribute-simple, attribute 61 | * @property {string} name 62 | * @property {boolean} isBoolean - whether this is a boolean attribute 63 | * @property {string} quote - used value quote (either empty, ' or ") 64 | * @property {?string} value - Empty string if a boolean attribute. Present for type: attribute-simple 65 | * @property {?Array} parts - present for type: attribute 66 | */ 67 | 68 | /** 69 | * @typedef {Object} ValuePart 70 | * @property {string} type - one of: text, ejs-escaped, ejs-eval 71 | * @property {string} content 72 | * @property {SourcePoint} start - inclusive. Present for types: ejs-escaped, ejs-eval 73 | * @property {SourcePoint} end - non inclusive. Present for types: ejs-escaped, ejs-eval 74 | */ 75 | 76 | /** 77 | * @param {string} source 78 | * @returns {Array} 79 | */ 80 | module.exports = function parse(source) { 81 | let pos = 0, 82 | line = 1, 83 | column = 1, 84 | rootTokens = [], 85 | tokens = rootTokens, 86 | elementsStack = [], 87 | // Keep the name of the current special element we are in 88 | specialElement = '' 89 | 90 | while (true) { 91 | let regex = specialElement ? nonSpecialTextStartRegex[specialElement] : nonTextStartRegex, 92 | match = exec(regex) 93 | 94 | if (!match) { 95 | // All remaining is text 96 | if (pos < source.length) { 97 | // Do not emit if empty 98 | let start = getSourcePoint() 99 | advanceTo(source.length) 100 | tokens.push(createContentToken('text', start)) 101 | } 102 | 103 | if (elementsStack.length) { 104 | throwSyntaxError(`Unclosed tags: ${elementsStack.map(e => e.name).join(', ')}`) 105 | } 106 | 107 | break 108 | } 109 | 110 | if (match.index !== pos) { 111 | // Emit text 112 | let start = getSourcePoint() 113 | advanceTo(match.index) 114 | tokens.push(createContentToken('text', start)) 115 | } 116 | 117 | advanceTo(regex.lastIndex) 118 | if (match[1].toUpperCase() === '!DOCTYPE ') { 119 | // Does not happend when in special element 120 | if (elementsStack.length) { 121 | throwSyntaxError('DOCTYPE is only allowed at top level') 122 | } 123 | tokens.push(readSimpleToken('doctype', tagEndRegex)) 124 | } else if (match[1] === '!--') { 125 | // Does not happend when in special element 126 | tokens.push(readSimpleToken('comment', commentEndRegex)) 127 | } else if (match[1] === '%=') { 128 | tokens.push(readSimpleToken('ejs-escaped', ejsEndRegex, true)) 129 | } else if (match[1] === '%-') { 130 | tokens.push(readSimpleToken('ejs-raw', ejsEndRegex, true)) 131 | } else if (match[1] === '%') { 132 | tokens.push(readSimpleToken('ejs-eval', ejsEndRegex, true)) 133 | } else if (match[1] === '/') { 134 | let closeTag = readCloseTag(), 135 | topElement = elementsStack.pop() 136 | 137 | if (!topElement) { 138 | throwSyntaxError(`Unmatched closing tag at top level: ${closeTag.name}`) 139 | } else if (topElement.name !== closeTag.name) { 140 | throwSyntaxError(`Unmatched closing tag: ${closeTag.name}, expected ${topElement.name}`) 141 | } 142 | 143 | topElement.end = closeTag.end 144 | let newTopElement = elementsStack[elementsStack.length - 1] 145 | tokens = newTopElement ? newTopElement.children : rootTokens 146 | specialElement = '' 147 | } else if (match[1] === '') { 148 | // Does not happend when in special element 149 | let openTag = readOpenTag() 150 | tokens.push(openTag) 151 | 152 | if (!openTag.isVoid) { 153 | // Prepare to parse element content 154 | elementsStack.push(openTag) 155 | tokens = openTag.children 156 | 157 | if (nonSpecialTextStartRegex.hasOwnProperty(openTag.name)) { 158 | // Enter special element state 159 | specialElement = openTag.name 160 | } 161 | } 162 | } 163 | } 164 | 165 | return rootTokens 166 | 167 | /** 168 | * Read a single token that has a known end 169 | * @param {string} type 170 | * @param {RegExp} endRegex 171 | * @param {boolean} trimRight - ignore starting empty spaces (\s) 172 | * @returns {Token} 173 | */ 174 | function readSimpleToken(type, endRegex, trimRight) { 175 | if (trimRight) { 176 | let matchTrim = exec(/\S/g) 177 | if (matchTrim) { 178 | advanceTo(matchTrim.index) 179 | } 180 | } 181 | let match = exec(endRegex) 182 | if (!match) { 183 | throwSyntaxError(`Unterminated ${type}`) 184 | } 185 | let start = getSourcePoint() 186 | advanceTo(match.index) 187 | let token = createContentToken(type, start) 188 | advanceTo(endRegex.lastIndex) 189 | return token 190 | } 191 | 192 | /** 193 | * Read a close tag token 194 | * @returns {Token} 195 | */ 196 | function readCloseTag() { 197 | let start = getSourcePoint(), 198 | match = tagCloseRegex.exec(source.substr(pos)) 199 | if (!match) { 200 | throwSyntaxError('Invalid close tag') 201 | } 202 | advanceTo(pos + match[0].length) 203 | let end = getSourcePoint() 204 | return { 205 | type: 'tag-close', 206 | start, 207 | end, 208 | name: match[1].toLowerCase() 209 | } 210 | } 211 | 212 | /** 213 | * Read an open tag token 214 | * @returns {Token} 215 | */ 216 | function readOpenTag() { 217 | // Read tag name 218 | let start = getSourcePoint(), 219 | match = tagNameRegex.exec(source.substr(pos)) 220 | if (!match) { 221 | throwSyntaxError('Invalid open tag') 222 | } 223 | let tagName = match[0].toLowerCase(), 224 | isVoid = voidElementsRegex.test(tagName) 225 | advanceTo(pos + tagName.length) 226 | 227 | // Keep reading content 228 | let selfClose = false, 229 | attributes = [], 230 | // Used to detect repeated attributes 231 | foundAttributeNames = [] 232 | while (true) { 233 | // Match using anchored regex 234 | let match = tagOpenContentStartRegex.exec(source.substr(pos)) 235 | if (!match) { 236 | throwSyntaxError('Invalid open tag') 237 | } 238 | 239 | advanceTo(pos + match[0].length) 240 | if (match[1] === '<%-') { 241 | throwSyntaxError('EJS unescaped tags are not allowed inside open tags') 242 | } else if (match[1] === '<%=') { 243 | throwSyntaxError('EJS escaped tags are not allowed inside open tags') 244 | } else if (match[1] === '<%') { 245 | throwSyntaxError('EJS eval tags are not allowed inside open tags') 246 | } else if (match[1] === '>') { 247 | break 248 | } else if (match[1] === '/>') { 249 | selfClose = true 250 | break 251 | } else { 252 | // Attribute start 253 | let lowerName = match[1].toLowerCase() 254 | if (foundAttributeNames.indexOf(lowerName) !== -1) { 255 | throwSyntaxError(`Repeated attribute ${match[1]} in open tag ${tagName}`) 256 | } 257 | foundAttributeNames.push(lowerName) 258 | attributes.push(readAttribute(lowerName)) 259 | } 260 | } 261 | 262 | if (!isVoid && selfClose) { 263 | throwSyntaxError('Self-closed tags for non-void elements are not allowed') 264 | } 265 | 266 | return { 267 | type: 'element', 268 | start, 269 | end: getSourcePoint(), 270 | name: tagName, 271 | isVoid, 272 | attributes, 273 | children: [] 274 | } 275 | } 276 | 277 | /** 278 | * @param {string} name 279 | * @returns {Attribute} 280 | */ 281 | function readAttribute(name) { 282 | // Read value (anchored match) 283 | let match = attributeValueRegex.exec(source.substr(pos)), 284 | isBoolean = booleanAttributeRegex.test(name), 285 | quote = '' 286 | 287 | if (match) { 288 | // The quote used quote is the first char in the matched value 289 | quote = match[1][0] 290 | if (quote !== '"' && quote !== '\'') { 291 | // Unquoted value 292 | quote = '' 293 | } 294 | 295 | advanceTo(pos + match[0].length) 296 | if (match[1] === '"' || match[1] === '\'') { 297 | // Quoted value 298 | let parts = readValueParts(match[1]) 299 | if (!parts.length || (parts.length === 1 && parts[0].type === 'text')) { 300 | // A simple quoted value that appeared not to be 301 | // Example: 'attr="a} 334 | */ 335 | function readValueParts(q) { 336 | let regex = q === '"' ? nonTextValueStartDoubleRegex : nonTextValueStartSingleRegex, 337 | parts = [] 338 | while (true) { 339 | let match = exec(regex) 340 | if (!match) { 341 | throwSyntaxError('Invalid quoted attribute value') 342 | } 343 | 344 | if (match.index !== pos) { 345 | // Emit text 346 | parts.push({ 347 | type: 'text', 348 | content: source.substring(pos, match.index) 349 | }) 350 | } 351 | 352 | advanceTo(regex.lastIndex) 353 | if (match[0] === '<%-') { 354 | throwSyntaxError('EJS unescaped tags are not allowed inside attribute values') 355 | } else if (match[0] === '<%=') { 356 | parts.push(readSimpleToken('ejs-escaped', ejsEndRegex, true)) 357 | } else if (match[0] === '<%') { 358 | throwSyntaxError('EJS eval tags are not allowed inside attribute values') 359 | } else { 360 | // End quote 361 | return parts 362 | } 363 | } 364 | } 365 | 366 | /** 367 | * Advance reading position, updating `pos`, `line` and `column` 368 | * @param {number} newPos - must be greater or equal to current pos 369 | */ 370 | function advanceTo(newPos) { 371 | let n = newPos - pos 372 | assert(n >= 0) 373 | while (n--) { 374 | if (source[pos] === '\n') { 375 | column = 1 376 | line++ 377 | } else { 378 | column++ 379 | } 380 | pos += 1 381 | } 382 | } 383 | 384 | /** 385 | * Execute a regex from the current position 386 | * @param {RegExp} regex 387 | * @returns {?Array} 388 | */ 389 | function exec(regex) { 390 | regex.lastIndex = pos 391 | return regex.exec(source) 392 | } 393 | 394 | /** 395 | * Return current source position 396 | * @returns {SourcePoint} 397 | */ 398 | function getSourcePoint() { 399 | return { 400 | pos, 401 | line, 402 | column 403 | } 404 | } 405 | 406 | /** 407 | * Create a simple, content-oriented token up to current position 408 | * @param {string} type - one of: text, ejs-eval, ejs-escaped, ejs-raw, comment, doctype 409 | * @param {SourcePoint} start 410 | * @returns {Token} 411 | */ 412 | function createContentToken(type, start) { 413 | let end = getSourcePoint() 414 | return { 415 | type, 416 | start, 417 | end, 418 | content: source.substring(start.pos, end.pos) 419 | } 420 | } 421 | 422 | /** 423 | * Throw a syntax error in the current position 424 | * @param {string} message 425 | * @throws {SyntaxError} 426 | */ 427 | function throwSyntaxError(message) { 428 | let curr = getSourcePoint(), 429 | snippet = getSnippet(source, curr.line, curr.line), 430 | err = new SyntaxError(`${message}\n${snippet}`) 431 | err.pos = getSourcePoint() 432 | throw err 433 | } 434 | } -------------------------------------------------------------------------------- /lib/prepareOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * @param {Object} [options={}] 5 | * @param {boolean} [options.compileDebug=true] 6 | * @param {string} [options.filename='ejs'] 7 | * @param {TransformerFn} [options.transformer] 8 | * @param {boolean} [options.strictMode=true] 9 | * @param {Array} [options.vars=[]] 10 | * @param {boolean} [options.sourceMap=false] 11 | * @returns {Object} 12 | */ 13 | module.exports = function (options = {}) { 14 | if (options.compileDebug === undefined) { 15 | options.compileDebug = true 16 | } 17 | if (options.filename === undefined) { 18 | options.filename = 'ejs' 19 | } 20 | if (options.strictMode === undefined) { 21 | options.strictMode = true 22 | } 23 | if (options.vars === undefined) { 24 | options.vars = [] 25 | } 26 | if (options.sourceMap === undefined) { 27 | options.sourceMap = false 28 | } 29 | 30 | return options 31 | } -------------------------------------------------------------------------------- /lib/reduce.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let prepareOptions = require('./prepareOptions'), 4 | sourceBuilder = require('./sourceBuilder'), 5 | escape = require('./escape'), 6 | validUnquotedRegex = /^[^\s>"'<=`]*$/, 7 | // Elements to keep inner whitespaces 8 | keepWhitespaceRegex = /^(script|style|pre|textarea)$/i, 9 | custom 10 | 11 | /** 12 | * Remove comments and transform fixed tokens back to text. 13 | * The returned array has strings for fixed content and Token instances for dynamic ones 14 | * The token types on the resulting array may have one of the types: ejs-eval, ejs-escaped, ejs-raw, source-builder 15 | * @param {Array} tokens 16 | * @param {Object} [options] - see {@link prepareOptions} 17 | * @returns {Array} 18 | */ 19 | module.exports = function (tokens, options) { 20 | options = prepareOptions(options) 21 | 22 | let newTokens = [], 23 | lastTextWasPlain = false, 24 | lastPlainTextWasSpaced = false 25 | 26 | appendTokens(tokens, false) 27 | 28 | return newTokens 29 | 30 | /** 31 | * @param {Array} tokens 32 | * @param {boolean} keepWhitespace 33 | */ 34 | function appendTokens(tokens, keepWhitespace) { 35 | for (let i = 0, len = tokens.length; i < len; i++) { 36 | let token = tokens[i] 37 | 38 | if (token.type === 'text') { 39 | appendText(token.content, !keepWhitespace) 40 | } else if (token.type === 'ejs-eval') { 41 | newTokens.push(token) 42 | } else if (token.type === 'ejs-escaped') { 43 | newTokens.push(token) 44 | lastTextWasPlain = false 45 | } else if (token.type === 'ejs-raw') { 46 | newTokens.push(token) 47 | lastTextWasPlain = false 48 | } else if (token.type === 'comment') { 49 | // Removed 50 | } else if (token.type === 'doctype') { 51 | appendText(``, false) 52 | } else if (token.type === 'element') { 53 | if (token.name === 'eh-content') { 54 | throw new Error('Unexpected eh-content tag') 55 | } else if (token.name === 'eh-placeholder') { 56 | // Custom element content placeholder 57 | newTokens.push(custom.preparePlaceholder(token, options)) 58 | lastTextWasPlain = false 59 | continue 60 | } else if (token.name.includes('-')) { 61 | // Custom element 62 | newTokens.push(custom.prepareContent(token, options)) 63 | lastTextWasPlain = false 64 | continue 65 | } 66 | 67 | appendText(`<${token.name}`, false) 68 | appendAttributes(token.attributes) 69 | appendText('>', false) 70 | 71 | if (!token.isVoid) { 72 | let keepChildWhitespace = keepWhitespace || keepWhitespaceRegex.test(token.name) 73 | appendTokens(token.children, keepChildWhitespace) 74 | appendText(``, false) 75 | } 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * Append to the content of the text token at the tip 82 | * (or add a new one if none exists yet) 83 | * @param {string} str 84 | * @param {boolean} isPlainText - remove some spaces 85 | */ 86 | function appendText(str, isPlainText) { 87 | let i = newTokens.length - 1, 88 | last = newTokens[i] 89 | 90 | if (isPlainText) { 91 | if (lastTextWasPlain && lastPlainTextWasSpaced) { 92 | // Remove preceding spaces, since the last plain text 93 | // ended in spaces 94 | str = str.trimLeft() 95 | } 96 | 97 | str = str.replace(/(\s)\s+/g, '$1') 98 | lastPlainTextWasSpaced = /^\s$/.test(str.substr(-1)) 99 | } 100 | lastTextWasPlain = isPlainText 101 | 102 | if (typeof last === 'string') { 103 | newTokens[i] += str 104 | } else { 105 | newTokens.push(str) 106 | } 107 | } 108 | 109 | /** 110 | * @param {Array} attributes 111 | */ 112 | function appendAttributes(attributes) { 113 | for (let i = 0, len = attributes.length; i < len; i++) { 114 | let attribute = attributes[i] 115 | 116 | if (attribute.type === 'attribute-simple') { 117 | let value = attribute.value 118 | 119 | if (attribute.name === 'class') { 120 | value = value.trim().replace(/\s+/g, ' ') 121 | } else if (value && attribute.isBoolean) { 122 | // Boolean attributes don't need a value 123 | value = '' 124 | } 125 | 126 | if (!value) { 127 | // Empty value is the default in HTML 128 | value = '' 129 | } else if (validUnquotedRegex.test(value)) { 130 | // No need to put around quotes 131 | value = `=${value}` 132 | } else { 133 | // Use original quote 134 | value = `=${attribute.quote}${value}${attribute.quote}` 135 | } 136 | appendText(` ${attribute.name}${value}`, false) 137 | } else if (attribute.type === 'attribute') { 138 | let firstPart = attribute.parts[0] 139 | if (attribute.isBoolean && 140 | attribute.parts.length === 1 && 141 | firstPart.type === 'ejs-escaped') { 142 | // Special case for , treat this as: 143 | // attr<%}%>> 144 | // > 145 | // Since attr is boolean, we don't want to output it 146 | // when `value` is falsy 147 | let subBuilder = sourceBuilder(options) 148 | subBuilder.add('(') 149 | subBuilder.addToken(firstPart) 150 | subBuilder.add(`)?" ${escape.js(attribute.name)}":""`) 151 | newTokens.push({ 152 | type: 'source-builder', 153 | start: firstPart.start, 154 | end: firstPart.end, 155 | sourceBuilder: subBuilder 156 | }) 157 | continue 158 | } 159 | 160 | appendText(` ${attribute.name}=${attribute.quote}`, false) 161 | appendAttributeParts(attribute.parts, attribute.name === 'class') 162 | appendText(attribute.quote, false) 163 | } 164 | } 165 | } 166 | 167 | /** 168 | * @param {Array} parts 169 | * @param {boolean} collapse 170 | */ 171 | function appendAttributeParts(parts, collapse) { 172 | for (let i = 0, len = parts.length; i < len; i++) { 173 | let part = parts[i] 174 | 175 | if (part.type === 'text') { 176 | let text = part.content 177 | if (collapse) { 178 | text = text.replace(/\s+/g, ' ') 179 | } 180 | appendText(text) 181 | } else if (part.type === 'ejs-escaped' || part.type === 'ejs-eval') { 182 | newTokens.push(part) 183 | } 184 | } 185 | } 186 | } 187 | 188 | custom = require('./custom') -------------------------------------------------------------------------------- /lib/sourceBuilder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let SourceNode = require('source-map').SourceNode 4 | 5 | /** 6 | * An efficient version of SourceMapBuilder with no actual source map generation 7 | * @class 8 | */ 9 | class SourceBuilder { 10 | constructor() { 11 | this.content = '' 12 | } 13 | 14 | /** 15 | * Push more compiled core 16 | * @param {string} text 17 | */ 18 | add(text) { 19 | this.content += text 20 | } 21 | 22 | /** 23 | * Push more compiled core 24 | * @param {string} text 25 | */ 26 | prepend(text) { 27 | this.content = text + this.content 28 | } 29 | 30 | /** 31 | * Push a JS token 32 | * @param {Token} token 33 | */ 34 | addToken(token) { 35 | this.content += token.content 36 | } 37 | 38 | /** 39 | * Push a child source builder 40 | * @param {SourceBuilder} builder 41 | */ 42 | addBuilder(builder) { 43 | this.content += builder.content 44 | } 45 | 46 | /** 47 | * @param {string} filename 48 | * @returns {{code: string, map: ?string, mapWithCode: ?string}} 49 | */ 50 | build() { 51 | return { 52 | code: this.content 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * This helper class allows to create compiled code and its source map progressively 59 | * @class 60 | */ 61 | class SourceMapBuilder extends SourceBuilder { 62 | constructor(filename) { 63 | super() 64 | 65 | this.sourceNode = new SourceNode 66 | this.filename = filename 67 | } 68 | 69 | add(text) { 70 | this.sourceNode.add(text) 71 | } 72 | 73 | prepend(text) { 74 | this.sourceNode.prepend(text) 75 | } 76 | 77 | addToken(token) { 78 | let lines = token.content.split('\n') 79 | for (let i = 0; i < lines.length; i++) { 80 | // I'm not sure if that's how it should be done, but if we add one source node with 81 | // multiple lines the source map consumer get pretty confused. 82 | // It'll wrongly emit mulitple mappings, one for each generated line, all pointing 83 | // to the same original position. 84 | // Instead, we emit one mapping for each generated line, but with the correct line (line + i) 85 | let originalLine = token.start.line + i, 86 | originalColumn = i ? 0 : token.start.column - 1, 87 | line = lines[i] + (i === lines.length - 1 ? '' : '\n') 88 | let node = new SourceNode(originalLine, originalColumn, this.filename, line) 89 | this.sourceNode.add(node) 90 | } 91 | } 92 | 93 | addBuilder(builder) { 94 | this.sourceNode.add(builder.sourceNode) 95 | } 96 | 97 | build(source) { 98 | let { 99 | code, 100 | map 101 | } = this.sourceNode.toStringWithSourceMap({ 102 | file: this.filename + '.js' 103 | }) 104 | let mapAsStr = map.toString() 105 | map.setSourceContent(this.filename, source) 106 | return { 107 | code, 108 | map: mapAsStr, 109 | mapWithCode: map.toString() 110 | } 111 | } 112 | } 113 | 114 | /** 115 | * Get a new builder instance 116 | * @param {Object} options - already prepared 117 | * @returns {SourceBuilder} 118 | */ 119 | module.exports = function (options) { 120 | if (options.sourceMap) { 121 | return new SourceMapBuilder(options.filename) 122 | } 123 | return new SourceBuilder 124 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ejs-html", 3 | "version": "5.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "balanced-match": { 8 | "version": "1.0.0", 9 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 10 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", 11 | "dev": true 12 | }, 13 | "brace-expansion": { 14 | "version": "1.1.8", 15 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", 16 | "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", 17 | "dev": true, 18 | "requires": { 19 | "balanced-match": "1.0.0", 20 | "concat-map": "0.0.1" 21 | } 22 | }, 23 | "browser-stdout": { 24 | "version": "1.3.0", 25 | "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", 26 | "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", 27 | "dev": true 28 | }, 29 | "commander": { 30 | "version": "2.11.0", 31 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", 32 | "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", 33 | "dev": true 34 | }, 35 | "concat-map": { 36 | "version": "0.0.1", 37 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 38 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 39 | "dev": true 40 | }, 41 | "debug": { 42 | "version": "3.1.0", 43 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 44 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 45 | "dev": true, 46 | "requires": { 47 | "ms": "2.0.0" 48 | } 49 | }, 50 | "diff": { 51 | "version": "3.3.1", 52 | "resolved": "https://registry.npmjs.org/diff/-/diff-3.3.1.tgz", 53 | "integrity": "sha512-MKPHZDMB0o6yHyDryUOScqZibp914ksXwAMYMTHj6KO8UeKsRYNJD3oNCKjTqZon+V488P7N/HzXF8t7ZR95ww==", 54 | "dev": true 55 | }, 56 | "escape-string-regexp": { 57 | "version": "1.0.5", 58 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 59 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", 60 | "dev": true 61 | }, 62 | "fs.realpath": { 63 | "version": "1.0.0", 64 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 65 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 66 | "dev": true 67 | }, 68 | "glob": { 69 | "version": "7.1.2", 70 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 71 | "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", 72 | "dev": true, 73 | "requires": { 74 | "fs.realpath": "1.0.0", 75 | "inflight": "1.0.6", 76 | "inherits": "2.0.3", 77 | "minimatch": "3.0.4", 78 | "once": "1.4.0", 79 | "path-is-absolute": "1.0.1" 80 | } 81 | }, 82 | "growl": { 83 | "version": "1.10.3", 84 | "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", 85 | "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", 86 | "dev": true 87 | }, 88 | "has-flag": { 89 | "version": "2.0.0", 90 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", 91 | "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", 92 | "dev": true 93 | }, 94 | "he": { 95 | "version": "1.1.1", 96 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 97 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", 98 | "dev": true 99 | }, 100 | "inflight": { 101 | "version": "1.0.6", 102 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 103 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 104 | "dev": true, 105 | "requires": { 106 | "once": "1.4.0", 107 | "wrappy": "1.0.2" 108 | } 109 | }, 110 | "inherits": { 111 | "version": "2.0.3", 112 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 113 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", 114 | "dev": true 115 | }, 116 | "minimatch": { 117 | "version": "3.0.4", 118 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 119 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 120 | "dev": true, 121 | "requires": { 122 | "brace-expansion": "1.1.8" 123 | } 124 | }, 125 | "minimist": { 126 | "version": "0.0.8", 127 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 128 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", 129 | "dev": true 130 | }, 131 | "mkdirp": { 132 | "version": "0.5.1", 133 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 134 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 135 | "dev": true, 136 | "requires": { 137 | "minimist": "0.0.8" 138 | } 139 | }, 140 | "mocha": { 141 | "version": "5.0.0", 142 | "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.0.0.tgz", 143 | "integrity": "sha512-ukB2dF+u4aeJjc6IGtPNnJXfeby5d4ZqySlIBT0OEyva/DrMjVm5HkQxKnHDLKEfEQBsEnwTg9HHhtPHJdTd8w==", 144 | "dev": true, 145 | "requires": { 146 | "browser-stdout": "1.3.0", 147 | "commander": "2.11.0", 148 | "debug": "3.1.0", 149 | "diff": "3.3.1", 150 | "escape-string-regexp": "1.0.5", 151 | "glob": "7.1.2", 152 | "growl": "1.10.3", 153 | "he": "1.1.1", 154 | "mkdirp": "0.5.1", 155 | "supports-color": "4.4.0" 156 | } 157 | }, 158 | "ms": { 159 | "version": "2.0.0", 160 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 161 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", 162 | "dev": true 163 | }, 164 | "once": { 165 | "version": "1.4.0", 166 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 167 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 168 | "dev": true, 169 | "requires": { 170 | "wrappy": "1.0.2" 171 | } 172 | }, 173 | "path-is-absolute": { 174 | "version": "1.0.1", 175 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 176 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 177 | "dev": true 178 | }, 179 | "should": { 180 | "version": "13.2.1", 181 | "resolved": "https://registry.npmjs.org/should/-/should-13.2.1.tgz", 182 | "integrity": "sha512-l+/NwEMO+DcstsHEwPHRHzC9j4UOE3VQwJGcMWSsD/vqpqHbnQ+1iSHy64Ihmmjx1uiRPD9pFadTSc3MJtXAgw==", 183 | "dev": true, 184 | "requires": { 185 | "should-equal": "2.0.0", 186 | "should-format": "3.0.3", 187 | "should-type": "1.4.0", 188 | "should-type-adaptors": "1.1.0", 189 | "should-util": "1.0.0" 190 | } 191 | }, 192 | "should-equal": { 193 | "version": "2.0.0", 194 | "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", 195 | "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", 196 | "dev": true, 197 | "requires": { 198 | "should-type": "1.4.0" 199 | } 200 | }, 201 | "should-format": { 202 | "version": "3.0.3", 203 | "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", 204 | "integrity": "sha1-m/yPdPo5IFxT04w01xcwPidxJPE=", 205 | "dev": true, 206 | "requires": { 207 | "should-type": "1.4.0", 208 | "should-type-adaptors": "1.1.0" 209 | } 210 | }, 211 | "should-type": { 212 | "version": "1.4.0", 213 | "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", 214 | "integrity": "sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=", 215 | "dev": true 216 | }, 217 | "should-type-adaptors": { 218 | "version": "1.1.0", 219 | "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", 220 | "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", 221 | "dev": true, 222 | "requires": { 223 | "should-type": "1.4.0", 224 | "should-util": "1.0.0" 225 | } 226 | }, 227 | "should-util": { 228 | "version": "1.0.0", 229 | "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.0.tgz", 230 | "integrity": "sha1-yYzaN0qmsZDfi6h8mInCtNtiAGM=", 231 | "dev": true 232 | }, 233 | "source-map": { 234 | "version": "0.7.0", 235 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.0.tgz", 236 | "integrity": "sha512-JsXsCYrKzxA5kU8LanQJHIPoEY3fEH5WYSMJ8Z77ESByI18VFEoxB46H2eNHqK2nVqTRjUe5DYvNHmyT3JOd1w==" 237 | }, 238 | "supports-color": { 239 | "version": "4.4.0", 240 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", 241 | "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", 242 | "dev": true, 243 | "requires": { 244 | "has-flag": "2.0.0" 245 | } 246 | }, 247 | "wrappy": { 248 | "version": "1.0.2", 249 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 250 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 251 | "dev": true 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ejs-html", 3 | "version": "5.1.5", 4 | "author": "Sitegui ", 5 | "description": "Embedded JavaScript HTML templates. An implementation of EJS focused on run-time performance, HTML syntax checking, minified HTML output and custom HTML elements.", 6 | "main": "./index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/sitegui/ejs-html" 10 | }, 11 | "keywords": [ 12 | "ejs", 13 | "html", 14 | "template", 15 | "engine", 16 | "minification", 17 | "custom elements", 18 | "web components" 19 | ], 20 | "dependencies": { 21 | "source-map": "^0.7.0" 22 | }, 23 | "license": "MIT", 24 | "engines": { 25 | "node": ">=6" 26 | }, 27 | "scripts": { 28 | "test": "mocha test" 29 | }, 30 | "devDependencies": { 31 | "mocha": "^5.0.0", 32 | "should": "^13.2.1" 33 | } 34 | } -------------------------------------------------------------------------------- /test/compile.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it*/ 2 | 'use strict' 3 | 4 | let compile = require('..').compile, 5 | sourceMap = require('source-map') 6 | require('should') 7 | 8 | describe('compile', () => { 9 | it('should compile to run in the server', () => { 10 | compile('Hi <%=name.first%> <%=name.last%>!', { 11 | vars: ['name'] 12 | })({ 13 | name: { 14 | first: 'Gui', 15 | last: 'S' 16 | } 17 | }).should.be.equal('Hi Gui S!') 18 | }) 19 | 20 | it('should compile to run in the client', () => { 21 | let code = compile.standAlone('Hi <%=name.first%> <%=name.last%>!', { 22 | vars: ['name'] 23 | }) 24 | 25 | // eslint-disable-next-line no-new-func 26 | let render = new Function('locals, renderCustom', code) 27 | 28 | render({ 29 | name: { 30 | first: 'Gui', 31 | last: 'S' 32 | } 33 | }).should.be.equal('Hi Gui S!') 34 | }) 35 | 36 | it('should support transformers', () => { 37 | compile('Hi

Deep

', { 38 | transformer: function transformer(tokens) { 39 | tokens.forEach(token => { 40 | if (token.type === 'element') { 41 | if (token.name === 'i') { 42 | token.name = 'em' 43 | } 44 | transformer(token.children) 45 | } 46 | }) 47 | } 48 | })().should.be.equal('Hi

Deep

') 49 | }) 50 | 51 | it('should add extended exception context', () => { 52 | let source = 'a\n<% throw new Error("hi") %>\nb', 53 | options = { 54 | filename: 'file.ejs' 55 | }, 56 | message = `file.ejs:2 57 | 1 | a 58 | 2 >> | <% throw new Error("hi") %> 59 | 3 | b 60 | 61 | hi` 62 | 63 | // Non-stand alone compilation 64 | compile(source, options).should.throw(message) 65 | 66 | // Stand alone compilation 67 | let code = compile.standAlone(source, options) 68 | // eslint-disable-next-line no-new-func 69 | let render = new Function('locals, renderCustom', code) 70 | render.should.throw(message) 71 | }) 72 | 73 | it('should not add extended exception context when compileDebug is false', () => { 74 | let source = 'a\n<% throw new Error("hi") %>\nb', 75 | options = { 76 | filename: 'file.ejs', 77 | compileDebug: false 78 | } 79 | 80 | // Non-stand alone compilation 81 | compile(source, options).should.throw('hi') 82 | 83 | // Stand alone compilation 84 | let code = compile.standAlone(source, options) 85 | // eslint-disable-next-line no-new-func 86 | let render = new Function('locals, renderCustom', code) 87 | render.should.throw('hi') 88 | }) 89 | 90 | it('should compile custom tags when compileDebug is false', () => { 91 | compile('', { 92 | compileDebug: false 93 | })({}, () => 'hi').should.be.equal('hi') 94 | }) 95 | 96 | it('should generate source map', function (done) { 97 | if (process.version < 'v8') { 98 | return this.skip() 99 | } 100 | 101 | let source = `Basic tags: <%= user %> <%- user %> <% if (true) { %> 102 | 103 | 104 | outside 105 | inside 106 | 107 | not named 108 | named 109 | <% } %>`, 110 | fn = compile(source, { 111 | sourceMap: true 112 | }) 113 | 114 | fn.code.should.be.eql('"use strict";locals=locals||{};let __c=locals.__contents||{};let __o="Basic tags: "+(__l.s=__l.e=1,__e(user))+" "+(__l.s=__l.e=1,(user))+" ";__l.s=__l.e=1;if (true) {\n' + 115 | '__o+="\\n"+(__l.s=3,__l.e=6,(renderCustom("custom-tag",{"simple":"yes","active":true,"concat":"a and "+String((__l.s=__l.e=3,b)),"obj":(__l.s=__l.e=3,{a: 2}),__contents:{"":"\\noutside\\n","named":"inside"}},__l.s=3,__l.e=6)))+"\\n"+(__l.s=__l.e=7,(__c[""]&&/\\S/.test(__c[""])?__c[""]:"not named"))+"\\n"+(__l.s=__l.e=8,(__c[""]&&/\\S/.test(__c[""])?__c[""]:"named"))+"\\n";__l.s=__l.e=9;}\n' + 116 | 'return __o;') 117 | fn.map.should.be.eql('{"version":3,"sources":["ejs"],"names":[],"mappings":"uGAAgB,I,uBAAY,I,qBAAW,W;iEACO,C,mIACO,C,wBAAe,M,kOAM9D,C","file":"ejs.js"}') 118 | fn.mapWithCode.should.be.eql('{"version":3,"sources":["ejs"],"names":[],"mappings":"uGAAgB,I,uBAAY,I,qBAAW,W;iEACO,C,mIACO,C,wBAAe,M,kOAM9D,C","file":"ejs.js","sourcesContent":["Basic tags: <%= user %> <%- user %> <% if (true) { %>\\n\\t\\t\\t\\">\\n\\t\\t\\t\\" obj=\\"<%= {a: 2} %>\\">\\n\\t\\t\\t\\toutside\\n\\t\\t\\t\\tinside\\n\\t\\t\\t\\n\\t\\t\\tnot named\\n\\t\\t\\tnamed\\n\\t\\t\\t<% } %>"]}') 119 | 120 | new sourceMap.SourceMapConsumer(fn.map).then(consumer => { 121 | consumer.computeColumnSpans() 122 | let codes = [] 123 | consumer.eachMapping(mapping => { 124 | if (!mapping.originalLine) { 125 | return 126 | } 127 | let length = mapping.lastGeneratedColumn - mapping.generatedColumn + 1, 128 | original = extract(source, mapping.originalLine, mapping.originalColumn, length), 129 | generated = extract(fn.code, mapping.generatedLine, mapping.generatedColumn, length) 130 | original.should.be.eql(generated) 131 | codes.push(original) 132 | }) 133 | 134 | codes.should.be.eql([ 135 | 'user', 136 | 'user', 137 | 'if (true) {', 138 | 'b', 139 | 'b', 140 | '{a: 2}', 141 | '}' 142 | ]) 143 | 144 | done() 145 | }) 146 | 147 | function extract(str, line, column, length) { 148 | return str.split('\n')[line - 1].slice(column, column + length) 149 | } 150 | }) 151 | 152 | it('should check for placeholder emptiness the same way regardless compileDebug', () => { 153 | compile('out in')({}).should.be.eql('out in') 154 | 155 | compile('out in', { 156 | compileDebug: false 157 | })({}).should.be.eql('out in') 158 | }) 159 | 160 | it('should check for boolean attributes the same way regardless compileDebug', () => { 161 | compile('')({ 162 | x: false 163 | }).should.be.eql('') 164 | 165 | compile('', { 166 | compileDebug: false 167 | })({ 168 | x: false 169 | }).should.be.eql('') 170 | }) 171 | }) -------------------------------------------------------------------------------- /test/createCode.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it*/ 2 | 'use strict' 3 | 4 | let ejs = require('..'), 5 | prepareOptions = ejs._prepareOptions, 6 | createCode = ejs.compile._createCode 7 | require('should') 8 | 9 | describe('createCode', () => { 10 | it('should handle special static cases', () => { 11 | check('', {}, false, ['return""']) 12 | check('', {}, true, ['""']) 13 | 14 | check('Hello', {}, false, ['return"Hello"']) 15 | check('Hello', {}, true, ['"Hello"']) 16 | }) 17 | 18 | it('should generate a single expression when possible', () => { 19 | check('Hello <%= locals.firstName %> <%= locals.lastName %>', { 20 | compileDebug: false 21 | }, false, [ 22 | 'locals=locals||{};', 23 | 'let __c=locals.__contents||{};', 24 | 'return "Hello "+__e(locals.firstName)+" "+__e(locals.lastName);' 25 | ]) 26 | check('Hello <%= locals.firstName %> <%= locals.lastName %>', { 27 | compileDebug: false 28 | }, true, ['"Hello "+__e(locals.firstName)+" "+__e(locals.lastName)']) 29 | 30 | check('<%- locals.firstName %> <%- locals.lastName %>', { 31 | compileDebug: false 32 | }, false, [ 33 | 'locals=locals||{};', 34 | 'let __c=locals.__contents||{};', 35 | 'return ""+(locals.firstName)+" "+(locals.lastName);' 36 | ]) 37 | check('<%- locals.firstName %> <%- locals.lastName %>', { 38 | compileDebug: false 39 | }, true, ['""+(locals.firstName)+" "+(locals.lastName)']) 40 | }) 41 | 42 | it('should compile with debug markers', () => { 43 | check([ 44 | 'First', 45 | '<%=a', 46 | '+b', 47 | '%> and <%= b %>' 48 | ].join('\n'), {}, false, [ 49 | 'locals=locals||{};', 50 | 'let __c=locals.__contents||{};', 51 | 'return "First\\n"+(__l.s=2,__l.e=3,__e(a\n+b))+" and "+(__l.s=__l.e=4,__e(b));' 52 | ]) 53 | 54 | check([ 55 | 'First', 56 | '<%=', 57 | 'a', 58 | '%> and <%= b %>' 59 | ].join('\n'), {}, true, [ 60 | '"First\\n"+(__l.s=__l.e=3,__e(a))+" and "+(__l.s=__l.e=4,__e(b))' 61 | ]) 62 | }) 63 | 64 | it('should generate multiple statements when needed', () => { 65 | check('<% if (true) { %>true<% } %>', { 66 | compileDebug: false 67 | }, false, [ 68 | 'locals=locals||{};', 69 | 'let __c=locals.__contents||{};', 70 | 'let __o="";', 71 | 'if (true) {\n', 72 | '__o+="true";', 73 | '}\n', 74 | 'return __o;' 75 | ]) 76 | check('<% if (true) { %>true<% } %>', { 77 | compileDebug: false 78 | }, true, [ 79 | '(function(){', 80 | 'let __o="";', 81 | 'if (true) {\n', 82 | '__o+="true";', 83 | '}\n', 84 | 'return __o;', 85 | '})()' 86 | ]) 87 | 88 | check('<% if (true) { %>true<% } %>', {}, false, [ 89 | 'locals=locals||{};', 90 | 'let __c=locals.__contents||{};', 91 | 'let __o="";', 92 | '__l.s=__l.e=1;', 93 | 'if (true) {\n', 94 | '__o+="true";', 95 | '__l.s=__l.e=1;', 96 | '}\n', 97 | 'return __o;' 98 | ]) 99 | check('<% if (true) { %>true<% } %>', {}, true, [ 100 | '(function(){', 101 | 'let __o="";', 102 | '__l.s=__l.e=1;', 103 | 'if (true) {\n', 104 | '__o+="true";', 105 | '__l.s=__l.e=1;', 106 | '}\n', 107 | 'return __o;', 108 | '})()' 109 | ]) 110 | }) 111 | 112 | it('should compile in sloppy mode', () => { 113 | check('<%= name %>', { 114 | strictMode: false, 115 | compileDebug: false 116 | }, false, [ 117 | 'locals=locals||{};', 118 | 'let __c=locals.__contents||{};', 119 | 'with(locals){', 120 | 'return __e(name);', 121 | '}' 122 | ]) 123 | }) 124 | 125 | it('should compile with explicit locals bindings', () => { 126 | check('<%= name %>', { 127 | vars: ['name'], 128 | compileDebug: false 129 | }, false, [ 130 | 'locals=locals||{};', 131 | 'let __c=locals.__contents||{};', 132 | 'let name=locals.name;', 133 | 'return __e(name);' 134 | ]) 135 | 136 | check('<%= a+b %>', { 137 | vars: ['a', 'b'], 138 | compileDebug: false 139 | }, false, [ 140 | 'locals=locals||{};', 141 | 'let __c=locals.__contents||{};', 142 | 'let a=locals.a,b=locals.b;', 143 | 'return __e(a+b);' 144 | ]) 145 | }) 146 | 147 | it('should', () => { 148 | check('<% a() %> ', {}, true, [ 149 | '(function(){', 150 | 'let __o="";', 151 | '__l.s=__l.e=1;', 152 | 'a()\n', 153 | '__o+=" ";', 154 | 'return __o;', 155 | '})()' 156 | ]) 157 | }) 158 | }) 159 | 160 | function check(source, options, asInnerExpression, code) { 161 | options = prepareOptions(options) 162 | let tokens = ejs.reduce(ejs.parse(source), options) 163 | 164 | let builder = createCode(tokens, options, asInnerExpression) 165 | builder.build(source).code.should.be.equal(code.join('')) 166 | } -------------------------------------------------------------------------------- /test/custom.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it*/ 2 | 'use strict' 3 | 4 | let compile = require('..').compile 5 | require('should') 6 | 7 | describe('custom', () => { 8 | let renderDialog 9 | it('should compile custom tag definition', () => { 10 | renderDialog = compile(`
11 |
12 | <%= title %> 13 | <% if (closable) { %> 14 |
X
15 | <% } %> 16 |
17 | 18 | 19 | 20 |
21 | 22 | 23 |
24 |
`, { 25 | vars: ['title', 'closable'] 26 | }) 27 | }) 28 | 29 | let renderView 30 | it('should compile custom tag usage', () => { 31 | renderView = compile(` 32 | HTML Content 33 | `) 34 | }) 35 | 36 | it('should render custom tags', () => { 37 | renderView({}, (name, locals) => { 38 | name.should.be.equal('custom-dialog') 39 | locals.should.be.eql({ 40 | title: 'Wanna Know?', 41 | closable: true, 42 | __contents: { 43 | '': '\nHTML Content\n' 44 | } 45 | }) 46 | return renderDialog(locals) 47 | }).should.be.equal(`
48 |
49 | Wanna Know? 50 |
X
51 |
52 | 53 | HTML Content 54 | 55 |
56 | 57 | 58 |
59 |
`) 60 | }) 61 | 62 | it('should support multiple and named placeholders', () => { 63 | check(` 64 | 65 | 66 | `, ` 67 | outside 68 | inside 69 | `, ` 70 | outside 71 | 72 | inside 73 | 74 | outside 75 | 76 | inside`) 77 | }) 78 | 79 | it('should allow passing complex JS values', () => { 80 | let myObj = {} 81 | compile('', { 82 | vars: ['someObj'] 83 | })({ 84 | someObj: myObj 85 | }, (_, locals) => { 86 | locals.ref.should.be.equal(myObj) 87 | }) 88 | }) 89 | 90 | it('should use default placeholder when no content is provided', () => { 91 | check('default', '', 'default') 92 | check('default', '\n', 'default') 93 | check('default', ' ', 'default') 94 | check('default', 'x', 'x') 95 | }) 96 | 97 | it('should not treat any boolean-like attribute as true', () => { 98 | check('<%=locals.bool%>', '', 'true') 99 | }) 100 | 101 | it('should turn dash notation to camel case', () => { 102 | check('<%=locals.userName%>', '', 'gui') 103 | }) 104 | }) 105 | 106 | function check(customSource, source, expected) { 107 | compile(source)({}, (_, locals) => compile(customSource)(locals)).should.be.equal(expected) 108 | } -------------------------------------------------------------------------------- /test/escape.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it*/ 2 | 'use strict' 3 | 4 | let escape = require('..').escape 5 | require('should') 6 | 7 | describe('escape', () => { 8 | it('should escape html', () => { 9 | escape.html('a && b << c >> d "" e \'\' f').should.be 10 | .equal('a && b << c >> d "" e '' f') 11 | }) 12 | 13 | it('should escape js value to put inside double quotes', () => { 14 | escape.js('a \\\\ b \n\n c \r\r d "" e').should.be 15 | .equal('a \\\\\\\\ b \\n\\n c \\r\\r d \\"\\" e') 16 | }) 17 | }) -------------------------------------------------------------------------------- /test/parse.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it*/ 2 | 'use strict' 3 | 4 | let parse = require('..').parse 5 | require('should') 6 | 7 | describe('parse', () => { 8 | it('should parse a literal text', () => { 9 | parse('A literal text').should.be.eql([{ 10 | type: 'text', 11 | start: getPos(''), 12 | end: getPos('A literal text'), 13 | content: 'A literal text' 14 | }]) 15 | 16 | parse('Multi\nline').should.be.eql([{ 17 | type: 'text', 18 | start: getPos(''), 19 | end: getPos('Multi\nline'), 20 | content: 'Multi\nline' 21 | }]) 22 | }) 23 | 24 | it('should parse EJS tags', () => { 25 | parse('<%eval%><% %><%=escaped%><%-raw%>literal <%% text').should.be.eql([{ 26 | type: 'ejs-eval', 27 | start: getPos('<%'), 28 | end: getPos('<%eval'), 29 | content: 'eval' 30 | }, { 31 | type: 'ejs-eval', 32 | start: getPos('<%eval%><% '), 33 | end: getPos('<%eval%><% '), 34 | content: '' 35 | }, { 36 | type: 'ejs-escaped', 37 | start: getPos('<%eval%><% %><%='), 38 | end: getPos('<%eval%><% %><%=escaped'), 39 | content: 'escaped' 40 | }, { 41 | type: 'ejs-raw', 42 | start: getPos('<%eval%><% %><%=escaped%><%-'), 43 | end: getPos('<%eval%><% %><%=escaped%><%-raw'), 44 | content: 'raw' 45 | }, { 46 | type: 'text', 47 | start: getPos('<%eval%><% %><%=escaped%><%-raw%>'), 48 | end: getPos('<%eval%><% %><%=escaped%><%-raw%>literal <%% text'), 49 | content: 'literal <%% text' 50 | }]) 51 | }) 52 | 53 | it('should parse comment tags', () => { 54 | parse('').should.be.eql([{ 55 | type: 'comment', 56 | start: getPos('')).should.be.eql([]) 36 | }) 37 | 38 | it('should parse doctype tags', () => { 39 | reduce(parse('')).should.be.eql(['']) 40 | }) 41 | 42 | it('should parse basic element tags', () => { 43 | reduce(parse('
')).should.be.eql(['
']) 44 | }) 45 | 46 | it('should parse open tags with literal attributes', () => { 47 | let source = '
ngle\' c="duble" d="" checked="yes!">
', 48 | expected = '
ngle\' c="duble" d checked>
' 49 | reduce(parse(source)).should.be.eql([expected]) 50 | }) 51 | 52 | it('should parse open tags with dynamic attributes', () => { 53 | let source = '
' 54 | reduce(parse(source)).should.be.eql([ 55 | '
' 61 | ]) 62 | }) 63 | 64 | it('should normalize whitespace between attributes', () => { 65 | minify('').should.be.equal('') 66 | }) 67 | 68 | it('should collapse whitespaces in html text', () => { 69 | minify(' no\n need for spaces ').should.be.equal(' no\nneed for spaces ') 70 | 71 | minify('even <%a%> between <%x%> js ta<%g%>s') 72 | .should.be.equal('even <%a%>between <%x%>js ta<%g%>s') 73 | }) 74 | 75 | it('should collapse whitespace in class attribute', () => { 76 | minify('').should.be.equal('') 77 | 78 | minify('') 79 | .should.be.equal('') 80 | }) 81 | 82 | it('should collapse boolean attributes', () => { 83 | minify('') 84 | .should.be.equal('') 85 | 86 | minify('') 87 | .should.be.equal('>') 88 | }) 89 | 90 | it('should keep whitespace inside
-like tags', () => {
 91 | 		minify('  x  
  x    x    
x ') 92 | .should.be.equal(' x
  x    x    
x ') 93 | }) 94 | 95 | it('should treat spaces around EJS tags correctly', () => { 96 | minify('before <%= 2 %> after').should.be.equal('before <%=2%> after') 97 | minify('before <%- 2 %> after').should.be.equal('before <%-2%> after') 98 | minify('before <% 2 %> after').should.be.equal('before <%2%>after') 99 | }) 100 | }) 101 | 102 | function getPos(str) { 103 | let lines = str.split('\n') 104 | return { 105 | pos: str.length, 106 | line: lines.length, 107 | column: lines[lines.length - 1].length + 1 108 | } 109 | } 110 | 111 | function minify(source) { 112 | return reduce(parse(source)).map(e => { 113 | if (typeof e === 'string') { 114 | return e 115 | } else if (e.type === 'source-builder') { 116 | return `<%-${e.sourceBuilder.build(source).code}%>` 117 | } 118 | let c = e.type === 'ejs-eval' ? '' : (e.type === 'ejs-raw' ? '-' : '=') 119 | return `<%${c}${e.content}%>` 120 | }).join('') 121 | } -------------------------------------------------------------------------------- /test/strict.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it*/ 2 | 'use strict' 3 | 4 | let compile = require('..').compile 5 | require('should') 6 | 7 | describe('strict', () => { 8 | it('should compile in strict mode by default', () => { 9 | compile('<% this.x = 1 %>').should.throw(/Cannot set property/) 10 | 11 | // eslint-disable-next-line no-new-func 12 | let render = new Function('locals, renderCustom', compile.standAlone('<% this.x = 2 %>')) 13 | render.should.throw(/Cannot set property/) 14 | }) 15 | 16 | it('should compile in sloppy mode', () => { 17 | compile('<% this.x = 3 %>', { 18 | strictMode: false 19 | })() 20 | global.x.should.be.equal(3) 21 | 22 | // eslint-disable-next-line no-new-func 23 | let render = new Function('locals, renderCustom', compile.standAlone('<% this.x = 4 %>', { 24 | strictMode: false 25 | })) 26 | render() 27 | global.x.should.be.equal(4) 28 | }) 29 | }) --------------------------------------------------------------------------------