├── .gitignore ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── bin └── topside ├── package-lock.json ├── package.json ├── resources └── front-end.png ├── rollup-cli.config.js ├── rollup.config.js ├── spec ├── README.md ├── fixtures │ ├── models │ │ ├── Character.ts │ │ ├── House.ts │ │ └── Message.ts │ └── templates │ │ ├── at_char.top.html │ │ ├── comments.top.html │ │ ├── conditionals.top.html │ │ ├── for.top.html │ │ ├── hello_world.top.html │ │ ├── houses.top.html │ │ ├── html.top.html │ │ ├── implicit_declaration.top.html │ │ ├── layout.top.html │ │ ├── missing_param.top.html │ │ ├── multiple_params.top.html │ │ ├── noparam.top.html │ │ ├── page.top.html │ │ ├── page2.top.html │ │ ├── special_chars.top.html │ │ └── text.top.html ├── references │ ├── at_char.html │ ├── comments.html │ ├── complex.html │ ├── conditionals.html │ ├── for.html │ ├── hello_world.html │ ├── html.html │ ├── layout_page.html │ ├── layout_page2.html │ ├── multiple_params.html │ ├── noparam.html │ ├── special_chars.html │ ├── text.html │ └── xss.html ├── tests │ ├── ERR │ │ ├── implicit_declaration.ts │ │ ├── layout_missing_param.ts │ │ ├── missing_param.ts │ │ └── missing_param_call.ts │ └── OK │ │ ├── at_char.ts │ │ ├── comments.ts │ │ ├── complex.ts │ │ ├── conditionals.ts │ │ ├── for.ts │ │ ├── hello_world.ts │ │ ├── html.ts │ │ ├── layout_page.ts │ │ ├── layout_page2.ts │ │ ├── multiple_params.ts │ │ ├── noparam.ts │ │ ├── special_chars.ts │ │ ├── text.ts │ │ └── xss.ts └── tsconfig.json ├── src ├── CompileError.ts ├── Compiler.ts ├── CompilerInterface.ts ├── ISourcePosition.ts ├── TextIterator.ts ├── Token.ts ├── Tokenizer.ts ├── checker.ts ├── cli.ts ├── fragments │ ├── conditionals.ts │ ├── for.ts │ ├── html.ts │ ├── null.ts │ ├── passthrough.ts │ ├── section.ts │ ├── text.ts │ └── wrapper.ts ├── rules │ ├── comment.ts │ ├── conditionals.ts │ ├── extends.ts │ ├── for.ts │ ├── html.ts │ ├── import.ts │ ├── param.ts │ ├── passthrough.ts │ ├── section.ts │ └── text.ts └── topside.ts ├── tools └── run-spec.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .idea 7 | dist 8 | compiled 9 | .awcache 10 | spec/output 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | script: 5 | - npm run build 6 | - npm run lint 7 | - npm run test 8 | deploy: 9 | provider: npm 10 | email: hadrien.milano@gmail.com 11 | api_key: 12 | secure: Hct/RVF/rh+LbHdqGejHURNNuXYMGQr1m7vGaH3op7Rtoo/HFVm6PgF6ZJ/QabaWYkDwnrd4U0nJTWNrAe2HzppmtIRH75JLvEkLIE8o4Gvgo66CmGzHj27Q3yA9CLv0Pe22iXQHaX2Zt2sPgQQbg1YJ1SUX+J/ZmT6Io/9oLelpKepD8ISdRE7ePWIZh04IKltz0w1JdZmzwXYWIep1A9Pse36hrm0dqB6GcXANPGjjXoQtpop/PsOvEHauA+aHc4ajknr76jH7xJAZ4mTNP/f7v2achHuwulKW9+qCsj4g8cbtJ1XW+0Giw/KCc2b4Y9dYiQnD/4e4KmglAEcpix+97MsPoKmA6BCz66iy0bh+wYekmK52Gux9Jn+xZkr7HOi5MDNiQZLXxlrv4jkdsxtetOFpW8VwWFlGTBIbOEIKK80WDbE87WXZXPXudubvpqYoGBOEmxOtk5VJtrtZjfyS2JGfyvN3xF0YbdnSO1xGKgYC9CufxttPS0anzaLt2QV711x6mORHZffXci63fEDgJACmFUhq7AKiN3PIpFLl98wTxb2bh56v79WS/vZe2hil1PWIAjPXCX2XcSTfx3cO8BEjXmkKmcnhXj7UjhrifK4G8PnIp2fNLzzvT4L1fo9kqBUD0TsiUaXLO3OO/ZrExxm2fbrpCQvVxzHQIIU= 13 | on: 14 | tags: true 15 | repo: hmil/topside 16 | skip_cleanup: true 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch Typescript", 8 | "program": "${workspaceRoot}/dist/cli", 9 | "args": ["${workspaceRoot}/spec/fixtures/templates/at_char.top.html"], 10 | "cwd": "${workspaceRoot}", 11 | "outFiles": [ 12 | "${workspaceRoot}/dist/**/*.js" 13 | ] 14 | }, 15 | { 16 | "type": "node", 17 | "request": "attach", 18 | "name": "Attach to Process", 19 | "port": 5858 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.trimTrailingWhitespace": false, 4 | "editor.trimAutoWhitespace": false, 5 | "editor.tabSize": 4 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Hadrien Milano 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/hmil/topside.svg?branch=master)](https://travis-ci.org/hmil/topside) 2 | [![Dependencies Status](https://david-dm.org/hmil/topside/status.svg)](https://david-dm.org/hmil/topside) 3 | [![Dev Dependencies](https://david-dm.org/hmil/topside/dev-status.svg)](https://david-dm.org/hmil/topside?type=dev) 4 | 5 | # Topside 6 | 7 | Topside is a templating language and compiler targetting TypeScript to bring type-safety to the view layer. 8 | 9 | ## Motivation 10 | 11 | ![front-end fail](https://raw.githubusercontent.com/hmil/topside/master/resources/front-end.png) 12 | *It was at this moment Matt knew he should've used Topside.* 13 | 14 | 15 | The view layer is often times a weak spot in web application frameworks. Unsafe templates allow you to publish code with typos and errors. Refactoring is hard because no tool can efficiently find references in the templates. 16 | 17 | Topside introduces type checking to the view layer. By doing so, common mistakes don't make it past the compilation stage. Moreover, editor features such as type-based autocompletion, definition and usage lookup and assisted refactoring are made possible. 18 | 19 | ### Scope 20 | 21 | Topside is not meant to be a replacement for front-end frameworks such as Vue or React. Topside templates produce text only, there is no event-binding or DOM parsing involved. This library targets server-side rendering. 22 | Topside is particularly suited for server-only apps or hybrid apps where a substancial amount of the view is generated on the server. 23 | 24 | ## Usage 25 | 26 | **Hint:** To make the most of Topside, I recommend using [VSCode](https://code.visualstudio.com/) with the [topside-vscode](https://github.com/hmil/topside-vscode) plugin (available in the extensions Marketplace). 27 | 28 | This package allows you to compile topside templates to TypeScript module. Each template exports a default function that takes named parameters as input and returns a string. 29 | 30 | Install the CLI compiler package and the TypeScript compiler. 31 | 32 | ```bash 33 | # Topside and its peer dependencies: 34 | npm install topside @types/escape-html escape-html 35 | # ts-node and typescript for this demo: 36 | npm install ts-node typescript 37 | ``` 38 | 39 | Create a file `template.top.html` 40 | ``` 41 | @param name: string; 42 | 43 | Hello @(name)! 44 | ``` 45 | 46 | Then compile the template to typescript with: 47 | ```bash 48 | node_modules/.bin/topside template.top.html 49 | ``` 50 | 51 | Use the generated template in your typescript project. For instance, use the following `main.ts` file: 52 | ```typescript 53 | import template from './template.top'; 54 | 55 | console.log(template({ 56 | name: 'Jason' 57 | })); 58 | ``` 59 | 60 | ```bash 61 | node_modules/.bin/ts-node main.ts 62 | ``` 63 | 64 | 65 | ## Template syntax 66 | 67 | The syntax is based off of the [Blade templating engine](https://laravel.com/docs/5.4/blade) used in [Laravel](https://laravel.com/). 68 | While there is no intention to be 100% compatible with Blade, templates should be easily convertible from one to the other. 69 | 70 | **Work in progress:** Many features are missing compared to Blade. Those will be added eventually but development effort is currently focused on tooling and IDE integration. 71 | Take a look at the `spec/fixtures/templates` directory for a comprehensive list of features. 72 | 73 | ### Basics & Differences with Blade 74 | 75 | In topside, all instructions start with `@`. Use braces to pass parameters to a rule. eg: 76 | ``` 77 | @if(data == 'world') 78 | Rule the @(data)! 79 | @endif 80 | ``` 81 | 82 | Note that contrary to many templating languages, including Blade, topside doesn't support curly-braces based syntax. 83 | 84 | ``` 85 | DON'T: {{ something }} 86 | DO: @( something ) 87 | ``` 88 | 89 | _side note: You may omit the braces when you pass data to a rule (eg. @if data == 'world') in which case everything until the end-of-line is evaluated and the end-of-line is not rendered. We recommend you stick to the brace-based syntax unless you need to hide the line-break from the rendered text._ 90 | 91 | The other main difference is that topside introduces the rules `@param` and `@include` in order to define the template interface. All template parameters must be declared with `@param` otherwise a type error will be raised. 92 | 93 | ### Text interpolation 94 | 95 | Display javascript expressions using the `@` directive: 96 | 97 | ``` 98 |

Hello @("John")

99 | ``` 100 | 101 | By default, values are html-escaped. Use `@html` to display unescaped text: 102 | 103 | ``` 104 |

Hello @html("John")

105 | ``` 106 | 107 | **To Blade users:** Don't use brackets-based syntax (`{{ text }}` and similar). In topside, all instructions start with `@`. 108 | 109 | ### Parameters 110 | 111 | Declare the parameters expected by your template using the `@param` directive: 112 | 113 | ``` 114 | @param name: string 115 | 116 |

Hello @(name)

117 | ``` 118 | 119 | ### Imports 120 | 121 | Often times you will need to pass custom models to your templates. Import the type definition like you would in a regular typescript file but with `@import` instead of `import` 122 | 123 | ``` 124 | @import User from 'path/to/User' 125 | 126 | @param user: User 127 | 128 |

Hello @(user.name)

129 | ``` 130 | 131 | ### Conditionals 132 | 133 | Use `@if`, `@elsif`, `@else` and `@endif` to render text conditionnally. 134 | 135 | ``` 136 | @if (user.role === Role.King) 137 | All hail the king! 138 | @elsif (user.role === Role.Queen) 139 | All hail the queen! 140 | @else 141 | Move it! 142 | @endif 143 | ``` 144 | 145 | ### Loops 146 | 147 | The `@for` rule is merely a translation to the `for` TypeScript construct. 148 | 149 | ``` 150 | @param animals: string[]; 151 | 152 | In the farm there are: 153 | @for(animal of animals) 154 | - @(animal) 155 | @endfor 156 | 157 | Each has a number: 158 | @for(i = 0 ; i < 10 ; i++) 159 | - @(i): @(animals[i]) 160 | @endfor 161 | ``` 162 | 163 | ### Litteral '@' symbol 164 | 165 | You could print a literal `@` by using string interpolation `@('@')` but topside provides a cleaner alternative: Simply type `@@` to print one `@`. 166 | `hello@@world` => `hello@world` 167 | 168 | ### Comments 169 | 170 | The `--` rule allows you to write comments in the template. Such comments are completely removed from the produced output. 171 | 172 | ``` 173 | @-- Comments 174 | @-- This comment will be stripped 175 | @-- entirely from the output 176 | 177 | I @--(don't)like the cake@--( is a lie) 178 | ``` 179 | 180 | ### Template Inheritance 181 | 182 | Template inheritance works very much like in Blade (as of Laravel 5.4). Since they made a great job of explaining how it works, the following section has been mostly copy-pasted from the Laravel docs. 183 | 184 | **warning:** In Topside, you should avoid quoting the section name 185 | DO: @section(foobar) 186 | NOT: @section('foobar') 187 | 188 | #### Defining A Layout 189 | 190 | Two of the primary benefits of using ~~Blade~~ Topside are _template inheritance_ and _sections_. To get started, let's take a look at a simple example. First, we will examine a "master" page layout. Since most web applications maintain the same general layout across various pages, it's convenient to define this layout as a single ~~Blade~~ Topside view: 191 | 192 | 193 | 194 | 195 | 196 | App Name - @yield(title) 197 | 198 | 199 | @section(sidebar) 200 | This is the master sidebar. 201 | @show 202 | 203 |
204 | @yield(content) 205 |
206 | 207 | 208 | 209 | As you can see, this file contains typical HTML mark-up. However, take note of the `@section` and `@yield` directives. The `@section` directive, as the name implies, defines a section of content, while the `@yield` directive is used to display the contents of a given section. 210 | 211 | Now that we have defined a layout for our application, let's define a child page that inherits the layout. 212 | 213 | #### Extending A Layout 214 | 215 | When defining a child view, use the Blade `@extends` directive to specify which layout the child view should "inherit". Views which extend a ~~Blade~~ Topside layout may inject content into the layout's sections using `@section` directives. Remember, as seen in the example above, the contents of these sections will be displayed in the layout using `@yield`: 216 | 217 | 218 | 219 | @extends './layouts/app.top' 220 | 221 | @section(title, 'Page Title') 222 | 223 | @section(sidebar) 224 | @parent 225 | 226 |

This is appended to the master sidebar.

227 | @endsection 228 | 229 | @section(content) 230 |

This is my body content.

231 | @endsection 232 | 233 | In this example, the `sidebar` section is utilizing the `@parent` directive to append (rather than overwriting) content to the layout's sidebar. The `@parent` directive will be replaced by the content of the layout when the view is rendered. 234 | -------------------------------------------------------------------------------- /bin/topside: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/cli.js') 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "topside", 3 | "version": "0.6.5", 4 | "description": "Type-safe templates for server-side TypeScript applications inspired by Laravel Blade", 5 | "keywords": [ 6 | "typescript", 7 | "template", 8 | "view" 9 | ], 10 | "main": "dist/topside.umd.js", 11 | "module": "dist/topside.es5.js", 12 | "typings": "dist/types/topside.d.ts", 13 | "files": [ 14 | "dist", 15 | "bin" 16 | ], 17 | "author": "Hadrien Milano ", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/hmil/topside.git" 21 | }, 22 | "bin": "./bin/topside", 23 | "license": "MIT", 24 | "engines": { 25 | "node": ">=6.0.0" 26 | }, 27 | "scripts": { 28 | "lint": "tslint -t codeFrame 'src/**/*.ts'", 29 | "prebuild": "rimraf dist", 30 | "dist": "rollup -c && rollup -c rollup-cli.config.js", 31 | "build": "tsc && npm run dist && rimraf compiled && typedoc --out dist/docs --target es6 --theme minimal src", 32 | "start": "tsc-watch --onSuccess \"npm run dist\"", 33 | "test": "ts-node tools/run-spec", 34 | "clean": "rimraf spec/output compiled dist" 35 | }, 36 | "devDependencies": { 37 | "@types/diff": "3.5.1", 38 | "@types/glob": "7.1.1", 39 | "@types/jest": "23.3.5", 40 | "@types/minimist": "1.2.0", 41 | "@types/mkdirp": "0.5.2", 42 | "@types/node": "10.12.0", 43 | "colors": "1.3.2", 44 | "cross-env": "5.2.0", 45 | "diff": "3.5.0", 46 | "lint-staged": "7.3.0", 47 | "lodash.camelcase": "4.3.0", 48 | "prettier": "1.14.3", 49 | "prompt": "1.0.0", 50 | "replace-in-file": "3.4.2", 51 | "require-self": "0.2.1", 52 | "rimraf": "2.6.2", 53 | "rollup": "0.66.6", 54 | "rollup-plugin-commonjs": "9.2.0", 55 | "rollup-plugin-node-resolve": "3.4.0", 56 | "rollup-plugin-sourcemaps": "0.4.2", 57 | "ts-node": "7.0.1", 58 | "tsc-watch": "1.0.30", 59 | "tslint": "5.11.0", 60 | "tslint-config-prettier": "1.15.0", 61 | "tslint-config-standard": "8.0.1", 62 | "typedoc": "0.13.0", 63 | "typescript": "3.1.3", 64 | "validate-commit-msg": "2.14.0" 65 | }, 66 | "peerDependencies": { 67 | "escape-html": "1.x", 68 | "@types/escape-html": ">=0.0.19" 69 | }, 70 | "dependencies": { 71 | "@types/escape-html": "0.0.20", 72 | "escape-html": "1.x", 73 | "glob": "7.1.3", 74 | "minimist": "1.2.0", 75 | "mkdirp": "0.5.1", 76 | "source-map": "0.7.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /resources/front-end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmil/topside/47dd4832a50890ffa7f44bd89974dd5bd46072de/resources/front-end.png -------------------------------------------------------------------------------- /rollup-cli.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import sourceMaps from 'rollup-plugin-sourcemaps' 4 | const pkg = require('./package.json') 5 | const camelCase = require('lodash.camelcase') 6 | 7 | const libraryName = 'topside' 8 | 9 | export default { 10 | entry: `compiled/cli.js`, 11 | targets: [ 12 | { dest: 'dist/cli.js', moduleName: 'cli', format: 'umd' } 13 | ], 14 | sourceMap: true, 15 | // Indicate here all modules required by cli 16 | external: [ 'minimist', 'fs', 'glob', 'path' ], 17 | output: { 18 | name: "topside" 19 | }, 20 | plugins: [ 21 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 22 | commonjs(), 23 | // Allow node_modules resolution, so you can use 'external' to control 24 | // which external modules to include in the bundle 25 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 26 | resolve(), 27 | 28 | // Resolve source maps to the original source 29 | sourceMaps() 30 | ] 31 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import sourceMaps from 'rollup-plugin-sourcemaps' 4 | const pkg = require('./package.json') 5 | const camelCase = require('lodash.camelcase') 6 | 7 | const libraryName = 'topside' 8 | 9 | export default { 10 | entry: `compiled/${libraryName}.js`, 11 | targets: [ 12 | { dest: pkg.main, moduleName: camelCase(libraryName), format: 'umd' }, 13 | { dest: pkg.module, format: 'es' } 14 | ], 15 | sourceMap: true, 16 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 17 | external: [], 18 | output: { 19 | name: "topside" 20 | }, 21 | plugins: [ 22 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 23 | commonjs(), 24 | // Allow node_modules resolution, so you can use 'external' to control 25 | // which external modules to include in the bundle 26 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 27 | resolve(), 28 | 29 | // Resolve source maps to the original source 30 | sourceMaps() 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | # The topside specification 2 | 3 | This functionnal test suite acts as a language specification 4 | and at the same time asserts that the engine complies to it. 5 | 6 | Any case that is not tested here is not part of the spec and 7 | may break at any time. 8 | 9 | ## Spec structure 10 | 11 | Look at fixtures/templates/*.top.html, those files cover the 12 | whole scope of the specified topside syntax. 13 | In tests/*.ts you will find usage examples for those templates. 14 | Each test file has a reference file of the same name under 15 | references/. 16 | Running a template through a test yields an **exact** match 17 | with the reference. Any change, may it be just a single whitespace 18 | will be considered a breaking change. 19 | 20 | ## Test files 21 | 22 | `tests/OK` => Those files are examples of good template usage. 23 | `tests/ERR` => Those illustrate cases where topside would detect an error. 24 | 25 | Additional edge cases are generated programmatically by the 26 | scripts in fixtures/generators. 27 | 28 | -------------------------------------------------------------------------------- /spec/fixtures/models/Character.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default class Character { 4 | constructor( 5 | public name: string, 6 | public gender: 'M' | 'F' | null, 7 | public children?: Character[]) { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /spec/fixtures/models/House.ts: -------------------------------------------------------------------------------- 1 | import Character from './Character'; 2 | 3 | export default class House { 4 | constructor( 5 | public readonly name: string, 6 | public readonly motto: string, 7 | public readonly people: Character[]) { 8 | } 9 | 10 | public getMotto(): string { 11 | return 'House ' + this.name + ' says: ' + this.motto; 12 | } 13 | 14 | public toString(): string { 15 | return this.name; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /spec/fixtures/models/Message.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default class Message { 4 | constructor( 5 | private value: string) { 6 | 7 | } 8 | 9 | getMessage() { 10 | return this.value; 11 | } 12 | } -------------------------------------------------------------------------------- /spec/fixtures/templates/at_char.top.html: -------------------------------------------------------------------------------- 1 | manu@@example.com 2 | @@ -------------------------------------------------------------------------------- /spec/fixtures/templates/comments.top.html: -------------------------------------------------------------------------------- 1 | @-- Comments 2 | @-- This comment will be stripped 3 | @-- entirely from the output 4 | 5 | I @--(don't)like the cake@--( is a lie) 6 | -------------------------------------------------------------------------------- /spec/fixtures/templates/conditionals.top.html: -------------------------------------------------------------------------------- 1 | 2 | @param safety: string 3 | 4 | @if safety === 'typesafe' 5 | 1x safe 6 | @elseif safety === 'tddsafe' 7 | 1x sorta safe 8 | @else 9 | 1x not safe 10 | @endif 11 | 12 | @if safety === 'tddsafe' 13 | 2x sorta safe 14 | @elseif safety === 'typesafe' 15 | 2x safe 16 | @else 17 | 2x not safe 18 | @endif 19 | 20 | @if safety === 'nottypesafe' 21 | 3x not safe 22 | @elseif safety === 'nottddsafe' 23 | 3x not sorta safe 24 | @else 25 | 3x safe 26 | @endif 27 | -------------------------------------------------------------------------------- /spec/fixtures/templates/for.top.html: -------------------------------------------------------------------------------- 1 | 2 | @param names: string[] 3 | 4 | ## Men who went to the moon 5 | @for(name of names) 6 | - @ name 7 | @endfor 8 | 9 | ## Numbered 10 | @for(i = 0 ; i < names.length ; i++) 11 | - @(i) @ names[i] 12 | @endfor -------------------------------------------------------------------------------- /spec/fixtures/templates/hello_world.top.html: -------------------------------------------------------------------------------- 1 | @import Message from '../../fixtures/models/Message'; 2 | 3 | @param motd: Message 4 | 5 |

Message of the day

6 |

@(motd.getMessage())

7 | -------------------------------------------------------------------------------- /spec/fixtures/templates/houses.top.html: -------------------------------------------------------------------------------- 1 | @import House from '../../fixtures/models/House'; 2 | 3 | @param house: House 4 | 5 |
6 |

Characters of house @(house)

7 |

@(house.getMotto())

8 |
    9 | @for(person of house.people) 10 |
  • @(person.name) @if(person.gender)[@(person.gender)]@endif() 11 | @if(person.children) 12 |
      13 | @for(child of person.children) 14 |
    • @(child.name) @if(child.gender)[@(child.gender)]@endif()
    • 15 | @endfor 16 |
    17 | @elseif(person.gender === 'M') 18 | This character is not a father. 19 | @else 20 | This character is not a mother. 21 | @endif 22 |
  • 23 | @endfor 24 |
25 |
26 | -------------------------------------------------------------------------------- /spec/fixtures/templates/html.top.html: -------------------------------------------------------------------------------- 1 | 2 | @param text: string 3 | 4 |

@html(text)

5 | -------------------------------------------------------------------------------- /spec/fixtures/templates/implicit_declaration.top.html: -------------------------------------------------------------------------------- 1 | @extends './page.top' 2 | 3 | @-- In order to use the `lang` variable defined in 'page.top', one must re-declare 4 | @-- the parameter. In this instance, an error is expected because 'lang' is not defined 5 | @-- in the current template. 6 | 7 | @(lang) -------------------------------------------------------------------------------- /spec/fixtures/templates/layout.top.html: -------------------------------------------------------------------------------- 1 | @import Message from '../../fixtures/models/Message'; 2 | 3 | @param motd: Message; 4 | 5 | Welcome to my layout! 6 | ===================== 7 | 8 | current page: @yield(page-title) 9 | 10 | Message of the day: @(motd.getMessage()) 11 | 12 | @-- using quotes in @section is possible but not recommended 13 | @section('infos') 14 | ## Informations 15 | @show 16 | 17 | ## Content 18 | 19 | @yield("content") 20 | 21 | ## The end! -------------------------------------------------------------------------------- /spec/fixtures/templates/missing_param.top.html: -------------------------------------------------------------------------------- 1 | @-- This will fail because name is not declared 2 | Hello @(name) -------------------------------------------------------------------------------- /spec/fixtures/templates/multiple_params.top.html: -------------------------------------------------------------------------------- 1 | 2 | @param text: string 3 | @param second: string 4 | 5 |

@(text)

6 |

@(second)

7 | -------------------------------------------------------------------------------- /spec/fixtures/templates/noparam.top.html: -------------------------------------------------------------------------------- 1 | 2 | @('This template has no argument.') 3 | -------------------------------------------------------------------------------- /spec/fixtures/templates/page.top.html: -------------------------------------------------------------------------------- 1 | @extends './layout.top' 2 | 3 | @param lang: string 4 | 5 | @section(page-title,`${lang} => stuff`) 6 | 7 | @section(infos) 8 | @parent 9 | 10 | Nothing going on 11 | @endsection 12 | 13 | @section(content) 14 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. 15 | @endsection 16 | -------------------------------------------------------------------------------- /spec/fixtures/templates/page2.top.html: -------------------------------------------------------------------------------- 1 | @import Message from '../../fixtures/models/Message'; 2 | 3 | @param motd: Message; 4 | 5 | @extends './page.top' 6 | 7 | @section(infos) 8 | @parent 9 | 10 | @(motd.getMessage()) 11 | @endsection 12 | 13 | @section(content) 14 | @parent 15 | Le lorem ipsum est utilisé pour se substituer à du texte dans les maquettes. 16 | @endsection 17 | -------------------------------------------------------------------------------- /spec/fixtures/templates/special_chars.top.html: -------------------------------------------------------------------------------- 1 | This template should produce a bitwise copy despite the weired characters it contains. 2 | \r\n 3 | s'ome 4 | spe"cific © 💩 -------------------------------------------------------------------------------- /spec/fixtures/templates/text.top.html: -------------------------------------------------------------------------------- 1 | 2 | @param text: string 3 | 4 |

@(text)

5 |

@text(text)

6 | -------------------------------------------------------------------------------- /spec/references/at_char.html: -------------------------------------------------------------------------------- 1 | manu@example.com 2 | @ -------------------------------------------------------------------------------- /spec/references/comments.html: -------------------------------------------------------------------------------- 1 | 2 | I like the cake 3 | -------------------------------------------------------------------------------- /spec/references/complex.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Characters of house Stark

5 |

House Stark says: Winter is coming

6 |
    7 | 8 |
  • Ned [M] 9 | 10 |
      11 | 12 |
    • Arya [F]
    • 13 | 14 |
    • Sansa [F]
    • 15 | 16 |
    • Robb [M]
    • 17 | 18 |
    • <strong>Jon</strong> [M]
    • 19 |
    20 |
  • 21 | 22 |
  • Catelyn [F] 23 | This character is not a mother. 24 |
  • 25 |
26 |
27 | -------------------------------------------------------------------------------- /spec/references/conditionals.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1x safe 4 | 5 | 2x safe 6 | 7 | 3x safe 8 | -------------------------------------------------------------------------------- /spec/references/for.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Men who went to the moon 4 | 5 | - Al Bean 6 | - Al Shepard 7 | - Buzz Aldrin 8 | - Charlie Duke 9 | - Dave Scott 10 | - Ed Mitchell 11 | - Gene Cernan 12 | - Jack Schmitt 13 | - James Irwin 14 | - John Watts Young 15 | - Neil Armstrong 16 | - Pete Conrad 17 | ## Numbered 18 | 19 | - 0 Al Bean 20 | - 1 Al Shepard 21 | - 2 Buzz Aldrin 22 | - 3 Charlie Duke 23 | - 4 Dave Scott 24 | - 5 Ed Mitchell 25 | - 6 Gene Cernan 26 | - 7 Jack Schmitt 27 | - 8 James Irwin 28 | - 9 John Watts Young 29 | - 10 Neil Armstrong 30 | - 11 Pete Conrad -------------------------------------------------------------------------------- /spec/references/hello_world.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Message of the day

4 |

Hello World!

5 | -------------------------------------------------------------------------------- /spec/references/html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | -------------------------------------------------------------------------------- /spec/references/layout_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Welcome to my layout! 4 | ===================== 5 | 6 | current page: Latin => stuff 7 | 8 | Message of the day: A l'ouest rien de nouveau 9 | 10 | 11 | 12 | ## Informations 13 | 14 | Nothing going on 15 | 16 | ## Content 17 | 18 | 19 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. 20 | 21 | 22 | ## The end! -------------------------------------------------------------------------------- /spec/references/layout_page2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Welcome to my layout! 4 | ===================== 5 | 6 | current page: French => stuff 7 | 8 | Message of the day: A l'ouest rien de nouveau 9 | 10 | 11 | 12 | 13 | ## Informations 14 | 15 | Nothing going on 16 | 17 | A l'ouest rien de nouveau 18 | 19 | ## Content 20 | 21 | 22 | 23 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. 24 | Le lorem ipsum est utilisé pour se substituer à du texte dans les maquettes. 25 | 26 | 27 | ## The end! -------------------------------------------------------------------------------- /spec/references/multiple_params.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

John

4 |

Cena

5 | -------------------------------------------------------------------------------- /spec/references/noparam.html: -------------------------------------------------------------------------------- 1 | 2 | This template has no argument. 3 | -------------------------------------------------------------------------------- /spec/references/special_chars.html: -------------------------------------------------------------------------------- 1 | This template should produce a bitwise copy despite the weired characters it contains. 2 | \r\n 3 | s'ome 4 | spe"cific © 💩 -------------------------------------------------------------------------------- /spec/references/text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

John Cena

4 |

John Cena

5 | -------------------------------------------------------------------------------- /spec/references/xss.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

<script>alert("pwned")</script>

4 |

<script>alert("pwned")</script>

5 | -------------------------------------------------------------------------------- /spec/tests/ERR/implicit_declaration.ts: -------------------------------------------------------------------------------- 1 | import Message from '../../fixtures/models/Message'; 2 | import template from '../../output/views/implicit_declaration.top'; 3 | 4 | process.stdout.write(template({ 5 | lang: 'Latin', 6 | motd: new Message('Hello world') 7 | })); 8 | -------------------------------------------------------------------------------- /spec/tests/ERR/layout_missing_param.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/page.top'; 2 | 3 | process.stdout.write(template({ 4 | lang: 'Latin' 5 | })); 6 | -------------------------------------------------------------------------------- /spec/tests/ERR/missing_param.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/missing_param.top'; 2 | 3 | process.stdout.write(template()); 4 | -------------------------------------------------------------------------------- /spec/tests/ERR/missing_param_call.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/html.top'; 2 | 3 | process.stdout.write(template({ 4 | })); 5 | -------------------------------------------------------------------------------- /spec/tests/OK/at_char.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/at_char.top'; 2 | 3 | process.stdout.write(template()); -------------------------------------------------------------------------------- /spec/tests/OK/comments.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/comments.top'; 2 | 3 | process.stdout.write(template()); -------------------------------------------------------------------------------- /spec/tests/OK/complex.ts: -------------------------------------------------------------------------------- 1 | import House from '../../fixtures/models/House'; 2 | import template from '../../output/views/houses.top'; 3 | 4 | process.stdout.write(template({ 5 | house: new House( 6 | 'Stark', 7 | 'Winter is coming', 8 | [{ 9 | name: 'Ned', gender: 'M', children: [ 10 | { 11 | name: 'Arya', gender: 'F' 12 | }, { 13 | name: 'Sansa', gender: 'F' 14 | }, { 15 | name: 'Robb', gender: 'M' 16 | }, { 17 | name: 'Jon', gender: 'M' 18 | }] 19 | }, { 20 | name: 'Catelyn', gender: 'F' 21 | }] 22 | ) 23 | })); 24 | -------------------------------------------------------------------------------- /spec/tests/OK/conditionals.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/conditionals.top'; 2 | 3 | process.stdout.write(template({ 4 | safety: 'typesafe' 5 | })); 6 | -------------------------------------------------------------------------------- /spec/tests/OK/for.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/for.top'; 2 | 3 | process.stdout.write(template({ 4 | names: [ 5 | 'Al Bean', 6 | 'Al Shepard', 7 | 'Buzz Aldrin', 8 | 'Charlie Duke', 9 | 'Dave Scott', 10 | 'Ed Mitchell', 11 | 'Gene Cernan', 12 | 'Jack Schmitt', 13 | 'James Irwin', 14 | 'John Watts Young', 15 | 'Neil Armstrong', 16 | 'Pete Conrad', 17 | ] 18 | })) -------------------------------------------------------------------------------- /spec/tests/OK/hello_world.ts: -------------------------------------------------------------------------------- 1 | import Message from '../../fixtures/models/Message'; 2 | import template from '../../output/views/hello_world.top'; 3 | 4 | process.stdout.write(template({ 5 | motd: new Message('Hello World!') 6 | })); 7 | -------------------------------------------------------------------------------- /spec/tests/OK/html.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/html.top'; 2 | 3 | process.stdout.write(template({ 4 | text: '' 5 | })); 6 | -------------------------------------------------------------------------------- /spec/tests/OK/layout_page.ts: -------------------------------------------------------------------------------- 1 | import Message from '../../fixtures/models/Message'; 2 | import template from '../../output/views/page.top'; 3 | 4 | process.stdout.write(template({ 5 | lang: 'Latin', 6 | motd: new Message('A l\'ouest rien de nouveau') 7 | })); 8 | -------------------------------------------------------------------------------- /spec/tests/OK/layout_page2.ts: -------------------------------------------------------------------------------- 1 | import Message from '../../fixtures/models/Message'; 2 | import template from '../../output/views/page2.top'; 3 | 4 | process.stdout.write(template({ 5 | lang: 'French', 6 | motd: new Message('A l\'ouest rien de nouveau') 7 | })); 8 | -------------------------------------------------------------------------------- /spec/tests/OK/multiple_params.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/multiple_params.top'; 2 | 3 | process.stdout.write(template({ 4 | text: 'John', 5 | second: 'Cena' 6 | })); 7 | -------------------------------------------------------------------------------- /spec/tests/OK/noparam.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/noparam.top'; 2 | 3 | process.stdout.write(template()); 4 | -------------------------------------------------------------------------------- /spec/tests/OK/special_chars.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/special_chars.top'; 2 | 3 | process.stdout.write(template()); 4 | -------------------------------------------------------------------------------- /spec/tests/OK/text.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/text.top'; 2 | 3 | process.stdout.write(template({ 4 | text: 'John Cena' 5 | })); 6 | -------------------------------------------------------------------------------- /spec/tests/OK/xss.ts: -------------------------------------------------------------------------------- 1 | import template from '../../output/views/text.top'; 2 | 3 | process.stdout.write(template({ 4 | text: '' 5 | })); 6 | -------------------------------------------------------------------------------- /spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "allowUnreachableCode": false, 9 | "strictNullChecks": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitAny": true, 13 | "noImplicitThis": true, 14 | "noImplicitReturns": true, 15 | "outDir": ".compiled", 16 | "typeRoots": [ 17 | "../node_modules/@types" 18 | ] 19 | }, 20 | "include": [ 21 | "**/*.ts", 22 | "**/*.top.html" 23 | ] 24 | } -------------------------------------------------------------------------------- /src/CompileError.ts: -------------------------------------------------------------------------------- 1 | export default class CompileError extends Error { 2 | constructor( 3 | message: string, 4 | public readonly line: number, 5 | public readonly ch: number 6 | ) { 7 | super(message); 8 | this.name = "CompileError"; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Compiler.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, FragmentCtr, Rule, RuleBook } from './CompilerInterface'; 2 | import CompileError from "./CompileError"; 3 | import { AtRuleToken, TextToken, Token } from "./Token"; 4 | import Tokenizer from "./Tokenizer"; 5 | import { SourceNode, CodeWithSourceMap } from 'source-map'; 6 | 7 | export interface CompilerOptions { 8 | file: string 9 | }; 10 | 11 | export default class Compiler { 12 | private fragmentsBefore: FragmentCtr[] = []; 13 | private fragmentsAfter: FragmentCtr[] = []; 14 | 15 | constructor(private rules: RuleBook) {} 16 | 17 | public use(rule: Rule): void { 18 | if (rule.name in this.rules) { 19 | throw new Error( 20 | "A rule with this name already exists: " + rule.name 21 | ); 22 | } 23 | this.rules[rule.name] = rule; 24 | } 25 | 26 | public addFragment(position: "before" | "after", f: FragmentCtr): void { 27 | if (position === "after") { 28 | this.fragmentsAfter.splice(0, 0, f); 29 | } else { 30 | this.fragmentsBefore.push(f); 31 | } 32 | } 33 | 34 | public compile(input: string, options: CompilerOptions): CodeWithSourceMap { 35 | try { 36 | const tokenizer = new Tokenizer(options.file); 37 | tokenizer.consume(input); 38 | const tokens = tokenizer.close(); 39 | return this.processTokens(tokens, options); 40 | } catch (e) { 41 | if (e.name === "CompileError") { 42 | let file = 'anonymous'; 43 | if (options && options.file != null) { 44 | file = options.file; 45 | } 46 | console.error(this.prettyPrintError(e, input, file)); 47 | } 48 | throw e; 49 | } 50 | } 51 | 52 | public processTokens(tokens: Token[], options: CompilerOptions): CodeWithSourceMap { 53 | const context = new Context(); 54 | 55 | for (let i of Object.keys(this.rules)) { 56 | let rule = this.rules[i]; 57 | if (rule.initContext) { 58 | rule.initContext(context); 59 | } 60 | } 61 | 62 | const fragments = tokens.reduce((acc, t) => { 63 | const rule = this.getRule(t); 64 | return acc.concat(rule.analyze(context, t)); 65 | }, [] as Fragment[]); 66 | 67 | const dummyPosition = { 68 | line: 1, ch: 0, file: options.file 69 | } 70 | 71 | let before = this.fragmentsBefore.map((f) => new f(dummyPosition)); 72 | let after = this.fragmentsAfter.map((f) => new f(dummyPosition)); 73 | 74 | const root = new SourceNode(0, 0, 'TODO', [...before, ...fragments, ...after] 75 | .map((f) => { 76 | return new SourceNode(f.line, f.ch, f.file || '', f.render(context)); 77 | }) 78 | ); 79 | 80 | return root.toStringWithSourceMap(); 81 | } 82 | 83 | private prettyPrintError(e: CompileError, text: string, file: string): string { 84 | let i = 0; 85 | 86 | for ( 87 | let line = 1; 88 | line < e.line && i < text.length; 89 | i++, line += text.charAt(i) === "\n" ? 1 : 0 90 | ); 91 | 92 | let pretty = text.substr(i + 1); 93 | let nextNL = pretty.indexOf("\n"); 94 | if (nextNL !== -1) { 95 | pretty = pretty.substr(0, nextNL); 96 | } 97 | 98 | const spacer = Array(e.ch).join(" "); 99 | pretty += "\n" + spacer + "^"; 100 | pretty += "\n" + e; 101 | 102 | return `\n${ pretty }\n at ${file}:${e.line}:${e.ch}\n`; 103 | } 104 | 105 | private getRule(t: Token): Rule { 106 | if (t.type === TextToken.type) { 107 | return this.rules._text; 108 | } else if (t.type === AtRuleToken.type) { 109 | if (t.ruleName === "") { 110 | return this.rules._default; 111 | } else if (t.ruleName in this.rules) { 112 | return this.rules[t.ruleName]; 113 | } else { 114 | throw new CompileError( 115 | `Unknown rule: "${t.ruleName}"`, 116 | t.line, 117 | t.ch 118 | ); 119 | } 120 | } 121 | 122 | throw new CompileError( 123 | `Unrecognized token type: ${t.type}`, 124 | t.line, 125 | t.ch 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/CompilerInterface.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from './ISourcePosition'; 2 | import { Token } from "./Token"; 3 | 4 | export class Context {} 5 | 6 | export interface FragmentCtr { 7 | new (position: ISourcePosition): Fragment; 8 | } 9 | 10 | export abstract class Fragment implements ISourcePosition { 11 | public readonly line: number; 12 | public readonly ch: number; 13 | public readonly file: string; 14 | 15 | constructor( 16 | position: ISourcePosition) { 17 | this.line = position.line; 18 | this.ch = position.ch; 19 | this.file = position.file; 20 | } 21 | 22 | public abstract render(ctx: Context): string; 23 | } 24 | 25 | export interface Rule { 26 | name: string; 27 | initContext?(ctx: Context): void; 28 | analyze(ctx: Context, t: Token): Fragment | Fragment[]; 29 | } 30 | 31 | export interface RuleBook { 32 | [key: string]: Rule; 33 | _text: Rule; 34 | _default: Rule; 35 | } 36 | -------------------------------------------------------------------------------- /src/ISourcePosition.ts: -------------------------------------------------------------------------------- 1 | export interface ISourcePosition { 2 | line: number; 3 | ch: number; 4 | file: string; 5 | } -------------------------------------------------------------------------------- /src/TextIterator.ts: -------------------------------------------------------------------------------- 1 | export default class TextIterator { 2 | private cursor: number = 0; 3 | private chunk: string = ""; 4 | private line: number = 1; 5 | private ch: number = 1; 6 | private sliceBegin: number = 0; 7 | 8 | public feedChunk(chunk: string): void { 9 | if (this.hasNext()) { 10 | throw new Error( 11 | "A new chunk appeared before the previous one was finished" 12 | ); 13 | } 14 | this.cursor = 0; 15 | this.chunk = chunk; 16 | } 17 | 18 | public markSliceBegin(): void { 19 | this.sliceBegin = this.cursor; 20 | } 21 | 22 | public getSlice(): string { 23 | return this.chunk.substring(this.sliceBegin, this.cursor); 24 | } 25 | 26 | public nextChar(): string { 27 | if (!this.hasNext()) { 28 | throw new Error('Illegal state: iterator cannot move forward!'); 29 | } 30 | this.cursor++; 31 | this.ch++; 32 | const c = this.chunk.charAt(this.cursor); 33 | if (c === "\n") { 34 | this.ch = 1; 35 | this.line++; 36 | } 37 | return c; 38 | } 39 | 40 | public currentChar(): string { 41 | return this.chunk.charAt(this.cursor); 42 | } 43 | 44 | public currentCharCode(): number { 45 | return this.currentChar().charCodeAt(0); 46 | } 47 | 48 | public nextCharCode(): number { 49 | return this.nextChar().charCodeAt(0); 50 | } 51 | 52 | public hasNext(): boolean { 53 | return this.cursor < this.chunk.length; 54 | } 55 | 56 | public getLine(): number { 57 | return this.line; 58 | } 59 | 60 | public getCh(): number { 61 | return this.ch; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Token.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from './ISourcePosition'; 2 | 3 | export class Token implements ISourcePosition { 4 | constructor( 5 | public readonly type: string, 6 | public readonly data: string, 7 | public readonly line: number, 8 | public readonly ch: number, 9 | public readonly file: string, 10 | public readonly ruleName: string 11 | ) {} 12 | } 13 | 14 | export abstract class TokenBuilder { 15 | public abstract readonly type: string; 16 | public data: string[] = []; 17 | public ruleName: string[] = []; 18 | 19 | constructor( 20 | public readonly line: number, 21 | public readonly ch: number, 22 | public readonly file: string 23 | ) {} 24 | 25 | public build(): Token { 26 | return new Token( 27 | this.type, 28 | this.data.join(''), 29 | this.line, 30 | this.ch, 31 | this.file, 32 | this.ruleName.join('') 33 | ); 34 | } 35 | } 36 | 37 | export class TextToken extends TokenBuilder { 38 | public static type = "TEXT"; 39 | public readonly type = TextToken.type; 40 | } 41 | 42 | export class AtRuleToken extends TokenBuilder { 43 | public static type = "AT_RULE"; 44 | public readonly type = AtRuleToken.type; 45 | } 46 | 47 | export class InterpolationToken extends TokenBuilder { 48 | public static type = "INTERPOLATION"; 49 | public readonly type = InterpolationToken.type; 50 | } 51 | -------------------------------------------------------------------------------- /src/Tokenizer.ts: -------------------------------------------------------------------------------- 1 | import CompileError from "./CompileError"; 2 | import { AtRuleToken, TextToken, Token, TokenBuilder } from "./Token"; 3 | import TextIterator from "./TextIterator"; 4 | 5 | type TokenizerState = (eof: boolean) => void; 6 | 7 | const CHARCODE_a = "a".charCodeAt(0); 8 | const CHARCODE_z = "z".charCodeAt(0); 9 | const CHARCODE_OPEN_PAREN = "(".charCodeAt(0); 10 | const CHARCODE_SPACE = " ".charCodeAt(0); 11 | const CHARCODE_NL = "\n".charCodeAt(0); 12 | const CHARCODE_MINUS = "-".charCodeAt(0); 13 | const CHARCODE_UNDERSCORE = "_".charCodeAt(0); 14 | 15 | export default class Tokenizer { 16 | private state: TokenizerState = this.state_text; 17 | private it: TextIterator = new TextIterator(); 18 | private charStack: string[] = []; 19 | private hasOpenParen: boolean = false; 20 | private ended: boolean = false; 21 | private tokens: Token[] = []; 22 | private currentToken: TokenBuilder; 23 | 24 | constructor( 25 | private readonly file: string) { 26 | this.currentToken = new TextToken(1, 1, file); 27 | } 28 | 29 | public consume(buf: string): void { 30 | this.assertNotEnded(); 31 | this.it.feedChunk(buf.replace(/\r/g, "")); 32 | while (this.it.hasNext()) { 33 | this.state(false); 34 | } 35 | } 36 | 37 | public close(): Token[] { 38 | this.assertNotEnded(); 39 | this.ended = true; 40 | this.state(true); // Send EOF to current state for parsing 41 | this.tokens.push(this.currentToken.build()); 42 | return this.tokens; 43 | } 44 | 45 | private assertNotEnded(): void { 46 | if (this.ended === true) { 47 | throw new Error("Parser already closed"); 48 | } 49 | } 50 | 51 | private assertNotEOF(eof: boolean) { 52 | if (eof) { 53 | throw new CompileError( 54 | "Unexpected end of file", 55 | this.it.getLine(), 56 | this.it.getCh() 57 | ); 58 | } 59 | } 60 | 61 | private state_text(eof: boolean): void { 62 | if (eof) return; 63 | this.it.markSliceBegin(); 64 | let c = ""; 65 | for ( 66 | c = this.it.currentChar(); 67 | this.it.hasNext(); 68 | c = this.it.nextChar() 69 | ) { 70 | if (c === '@') { 71 | this.currentToken.data.push(this.it.getSlice()); 72 | this.state = this.state_atRuleBegin; 73 | return; 74 | } 75 | } 76 | this.currentToken.data.push(this.it.getSlice()); 77 | } 78 | 79 | 80 | /** 81 | * @@ 82 | * ^ 83 | * Here 84 | */ 85 | private state_atChar(eof: boolean): void { 86 | this.assertNotEOF(eof); 87 | this.it.nextChar(); 88 | this.state = this.state_text; 89 | this.currentToken.data.push('@'); 90 | } 91 | 92 | /** 93 | * @rule (some data) 94 | * ^ 95 | * Here 96 | */ 97 | private state_atRuleBegin(eof: boolean): void { 98 | this.assertNotEOF(eof); 99 | const c = this.it.nextChar(); 100 | if (c === '@') { 101 | this.state = this.state_atChar; 102 | } else { 103 | this.flushToken( 104 | new AtRuleToken(this.it.getLine(), this.it.getCh() - 1, this.file) 105 | ); 106 | this.state = this.state_atRuleName; 107 | } 108 | } 109 | 110 | /** 111 | * @rule (some data) 112 | * ^^^^ 113 | * Here 114 | */ 115 | private state_atRuleName(eof: boolean): void { 116 | if (eof) { 117 | // Rule with no parameter 118 | return; 119 | } 120 | this.it.markSliceBegin(); 121 | let c = 0; 122 | for ( 123 | c = this.it.currentCharCode(); 124 | this.it.hasNext(); 125 | c = this.it.nextCharCode() 126 | ) { 127 | if ((c < CHARCODE_a || c > CHARCODE_z) && c !== CHARCODE_MINUS && c !== CHARCODE_UNDERSCORE) { 128 | if ( 129 | c !== CHARCODE_NL && 130 | c !== CHARCODE_OPEN_PAREN && 131 | c !== CHARCODE_SPACE 132 | ) { 133 | throw new CompileError( 134 | 'Unexpected char: "' + 135 | String.fromCharCode(c) + 136 | '"\nexpected "[a-z_-]", "(", space or end of line', 137 | this.it.getLine(), 138 | this.it.getCh() - 1 139 | ); 140 | } 141 | this.currentToken.ruleName.push(this.it.getSlice()); 142 | this.state = this.state_atRuleAwaitData; 143 | return; 144 | } 145 | } 146 | this.currentToken.ruleName.push(this.it.getSlice()); 147 | } 148 | 149 | /** 150 | * @rule (some data) 151 | * ^^^ 152 | * Here 153 | */ 154 | private state_atRuleAwaitData(eof: boolean): void { 155 | if (eof) { 156 | return; 157 | } 158 | let c = ""; 159 | for ( 160 | c = this.it.currentChar(); 161 | this.it.hasNext(); 162 | c = this.it.nextChar() 163 | ) { 164 | switch (c) { 165 | // Ignore spaces 166 | case " ": 167 | break; 168 | // Rule has no data. It ends now 169 | case "\n": 170 | this.state = this.state_atRuleEnd; 171 | this.flushToken( 172 | new TextToken(this.it.getLine(), this.it.getCh(), this.file) 173 | ); 174 | return; 175 | // Rule has parenthesized data, it ends when the parenthesis is matched. 176 | case "(": 177 | this.state = this.state_atRuleOpenParen; 178 | return; 179 | // Rule has unparenthesized data, it ends when the line ends. 180 | default: 181 | this.state = this.state_atRuleData; 182 | return; 183 | } 184 | } 185 | } 186 | 187 | /** 188 | * @rule (some() data) 189 | * ^ 190 | * Here 191 | */ 192 | private state_atRuleOpenParen(eof: boolean): void { 193 | this.assertNotEOF(eof); 194 | this.hasOpenParen = true; 195 | this.charStack.push("("); 196 | this.it.nextChar(); 197 | this.state = this.state_atRuleData; 198 | } 199 | 200 | /** 201 | * @rule (some data) 202 | * ^^^^^^^^^ 203 | * Here 204 | */ 205 | private state_atRuleData(eof: boolean): void { 206 | this.assertNotEOF(eof); 207 | this.it.markSliceBegin(); 208 | let c = ""; 209 | for ( 210 | c = this.it.currentChar(); 211 | this.it.hasNext(); 212 | c = this.it.nextChar() 213 | ) { 214 | switch (c) { 215 | case "(": 216 | if (this.hasOpenParen) { 217 | this.charStack.push(c); 218 | } 219 | break; 220 | case ")": 221 | if (this.hasOpenParen) { 222 | let stacked = this.charStack.pop(); 223 | if (stacked !== "(") { 224 | throw new CompileError( 225 | "Unmatched parenthesis", 226 | this.it.getLine(), 227 | this.it.getCh() 228 | ); 229 | } 230 | if (this.charStack.length === 0) { 231 | this.currentToken.data.push(this.it.getSlice()); 232 | this.flushToken( 233 | new TextToken( 234 | this.it.getLine(), 235 | this.it.getCh(), 236 | this.file 237 | ) 238 | ); 239 | this.state = this.state_atRuleEnd; 240 | this.hasOpenParen = false; 241 | return; 242 | } 243 | } 244 | break; 245 | case "\n": 246 | if (!this.hasOpenParen) { 247 | this.currentToken.data.push(this.it.getSlice()); 248 | this.flushToken( 249 | new TextToken(this.it.getLine(), this.it.getCh(), this.file) 250 | ); 251 | this.state = this.state_atRuleEnd; 252 | return; 253 | } 254 | break; 255 | } 256 | } 257 | this.currentToken.data.push(this.it.getSlice()); 258 | } 259 | 260 | /** 261 | * @rule (some data) 262 | * ^ 263 | * Here 264 | * 265 | * @simplerule 266 | * ^ 267 | * or here 268 | */ 269 | private state_atRuleEnd(eof: boolean): void { 270 | this.assertNotEOF(eof); 271 | this.it.nextChar(); 272 | this.state = this.state_text; 273 | } 274 | 275 | private flushToken(nextToken: TokenBuilder): void { 276 | this.tokens.push(this.currentToken.build()); 277 | this.currentToken = nextToken; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/checker.ts: -------------------------------------------------------------------------------- 1 | import * as ts from "typescript"; 2 | import { SourceMapConsumer } from 'source-map'; 3 | import * as fs from 'fs'; 4 | 5 | export function check(fileNames: string[], options: ts.CompilerOptions): void { 6 | let program = ts.createProgram(fileNames, options); 7 | let emitResult = program.emit(); 8 | 9 | const diags = (emitResult.diagnostics instanceof Array) ? emitResult.diagnostics : [emitResult.diagnostics]; 10 | 11 | let allDiagnostics = ts.getPreEmitDiagnostics(program).concat(diags); 12 | 13 | allDiagnostics.forEach(async diagnostic => { 14 | if (diagnostic.file == null || diagnostic.start == null) return; 15 | 16 | console.log('Diagnostic in ' + diagnostic.file.fileName); 17 | 18 | let { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); 19 | 20 | let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); 21 | 22 | try { 23 | const consumer = await new SourceMapConsumer(fs.readFileSync(diagnostic.file.fileName + '.map').toString()); 24 | const origPos = consumer.originalPositionFor({ 25 | line: line + 1, 26 | column: character + 1 27 | }); 28 | console.log(`${origPos.source} (${origPos.line},${origPos.column}): ${message}`); 29 | } catch (e) { 30 | console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`); 31 | } 32 | 33 | }); 34 | 35 | let exitCode = emitResult.emitSkipped ? 1 : 0; 36 | console.log(`Process exiting with code '${exitCode}'.`); 37 | // process.exit(exitCode); 38 | } 39 | 40 | // for (let i of process.argv.slice(2)) { 41 | // check([i], { 42 | // noEmitOnError: true, 43 | // "moduleResolution": ts.ModuleResolutionKind.NodeJs, 44 | // "target": ts.ScriptTarget.ES5, 45 | // "module": ts.ModuleKind.CommonJS, 46 | // // "lib": ["es2015", "es2016", "es2017", "dom"], 47 | // "strict": true, 48 | // "allowUnreachableCode": true, 49 | // "strictNullChecks": true, 50 | // "noUnusedLocals": true, 51 | // "noUnusedParameters": true, 52 | // "noImplicitAny": true, 53 | // "noImplicitThis": true, 54 | // "noImplicitReturns": true, 55 | // "outDir": ".compiled", 56 | // }); 57 | // } -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { compiler } from "./topside"; 2 | 3 | import * as minimist from 'minimist'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import * as glob from 'glob'; 7 | import * as mkdirp from 'mkdirp'; 8 | 9 | let argv = minimist(process.argv.slice(2)); 10 | let outDir: string | null = null; 11 | let baseDir = process.cwd(); 12 | 13 | function processFile(arg: string): void { 14 | const file = path.resolve(arg); 15 | const template = fs.readFileSync(file).toString(); 16 | const compiled = compiler.compile(template, { 17 | file 18 | }); 19 | 20 | const filePath = path.parse(arg); 21 | filePath.ext = ".ts"; 22 | filePath.base = filePath.name + filePath.ext; 23 | if (outDir) { 24 | filePath.dir = path.resolve(outDir, path.relative(baseDir, filePath.dir)); 25 | } 26 | mkdirp(filePath.dir, function (err: any) { 27 | if (err) { 28 | console.error(err); 29 | } else { 30 | const dest = path.resolve(path.format(filePath)); 31 | fs.writeFileSync(dest, compiled.code + "\n//# sourceMappingURL=" + filePath.name + '.ts.map'); 32 | fs.writeFileSync(dest + '.map', compiled.map); 33 | } 34 | }); 35 | 36 | } 37 | 38 | if (argv.outDir) { 39 | outDir = path.resolve(process.cwd(), argv.outDir); 40 | } 41 | 42 | if (argv.baseDir) { 43 | baseDir = path.resolve(process.cwd(), argv.baseDir); 44 | } 45 | 46 | for (let i = 0 ; i < argv._.length ; i++) { 47 | glob(argv._[i], (err, files) => { 48 | if (err) throw err; 49 | files.forEach(processFile); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/fragments/conditionals.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from '../ISourcePosition'; 2 | import { Fragment } from '../CompilerInterface'; 3 | 4 | 5 | export class IfFragment extends Fragment { 6 | constructor( 7 | position: ISourcePosition, 8 | private readonly expr: string) { 9 | super(position); 10 | } 11 | 12 | public render(): string { 13 | return ( 14 | '" + \n' + 15 | '(function() {\n' + 16 | ' if (' + 17 | this.expr + 18 | ') {\n' + 19 | ' return ("' 20 | ); 21 | } 22 | } 23 | 24 | export class ElseIfFragment extends Fragment { 25 | constructor( 26 | position: ISourcePosition, 27 | private readonly expr: string) { 28 | super(position); 29 | } 30 | 31 | public render(): string { 32 | return ( 33 | '");\n' + 34 | ' } else if (' + 35 | this.expr + 36 | ') {\n' + 37 | ' return ("' 38 | ); 39 | } 40 | } 41 | 42 | export class ElseFragment extends Fragment { 43 | 44 | public render(): string { 45 | return '");\n' + ' } else {\n' + ' return ("'; 46 | } 47 | } 48 | 49 | export class EndIfFragment extends Fragment { 50 | 51 | constructor(position: ISourcePosition, private includeReturn: boolean) { 52 | super(position); 53 | } 54 | 55 | public render(): string { 56 | return `");\n }\n ${this.includeReturn ? 'return "";' : ''}}()) + "`; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/fragments/for.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from '../ISourcePosition'; 2 | import { Fragment } from '../CompilerInterface'; 3 | 4 | 5 | export class BeginForFragment extends Fragment { 6 | constructor( 7 | position: ISourcePosition, 8 | private readonly expr: string) { 9 | super(position); 10 | } 11 | 12 | public render(): string { 13 | return ( 14 | '" + \n' + 15 | '(function() {\n' + 16 | ' let __acc = "";\n' + 17 | ' for (let ' + 18 | this.expr + 19 | ') {\n' + 20 | ' __acc += ("' 21 | ); 22 | } 23 | } 24 | 25 | export class EndForFragment extends Fragment { 26 | public render(): string { 27 | return '");\n' + ' }\n' + ' return __acc;\n' + '}()) + "'; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/fragments/html.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from '../ISourcePosition'; 2 | import { Fragment } from '../CompilerInterface'; 3 | 4 | 5 | export class HtmlFragment extends Fragment { 6 | constructor( 7 | position: ISourcePosition, 8 | private readonly html: string) { 9 | super(position); 10 | } 11 | 12 | public render(): string { 13 | return '" + (' + this.html + ') + "'; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/fragments/null.ts: -------------------------------------------------------------------------------- 1 | import { Fragment } from "../CompilerInterface"; 2 | 3 | export default class NullFragment extends Fragment { 4 | public render(): string { 5 | return ''; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/fragments/passthrough.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from '../ISourcePosition'; 2 | import { Fragment } from '../CompilerInterface'; 3 | 4 | export class PassThroughFragment extends Fragment { 5 | constructor( 6 | position: ISourcePosition, 7 | private readonly text: string) { 8 | super(position); 9 | } 10 | 11 | public render(): string { 12 | return JSON.stringify(this.text).slice(1, -1); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/fragments/section.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from '../ISourcePosition'; 2 | import { Fragment } from '../CompilerInterface'; 3 | 4 | 5 | export class InlineSectionFragment extends Fragment { 6 | constructor( 7 | position: ISourcePosition, 8 | private readonly name: string, 9 | private readonly body: string) { 10 | super(position); 11 | } 12 | 13 | public render(): string { 14 | return `" + (() => { 15 | const __sectionName = "${this.name}"; 16 | __sections[__sectionName] = ((__parent: () => string) => __safeSection(__safeChildSections[__sectionName])( 17 | () => __escape(${this.body}))); 18 | return ""; 19 | })() + "`; 20 | } 21 | } 22 | 23 | export class BeginSectionFragment extends Fragment { 24 | constructor( 25 | position: ISourcePosition, 26 | private readonly name: string) { 27 | super(position); 28 | } 29 | 30 | public render(): string { 31 | return `" + (() => { 32 | const __sectionName = "${this.name}"; 33 | __sections[__sectionName] = ((__parent: () => string) => __safeSection(__safeChildSections[__sectionName])(() => "`; 34 | } 35 | } 36 | 37 | export class EndSectionFragment extends Fragment { 38 | 39 | public render(): string { 40 | return `")); 41 | return ""; 42 | })() + "`; 43 | } 44 | } 45 | 46 | export class ParentFragment extends Fragment { 47 | 48 | public render(): string { 49 | return `" + __parent() + "`; 50 | } 51 | } 52 | 53 | export class YieldFragment extends Fragment { 54 | constructor( 55 | position: ISourcePosition, 56 | private readonly name: string) { 57 | super(position); 58 | } 59 | 60 | public render(): string { 61 | return `" + __sections["${this.name}"](() => "")() + "`; 62 | } 63 | } 64 | 65 | export class ShowFragment extends Fragment { 66 | 67 | public render(): string { 68 | return `")); 69 | return __sections[__sectionName](() => "")(); 70 | })() + "`; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/fragments/text.ts: -------------------------------------------------------------------------------- 1 | import { ISourcePosition } from '../ISourcePosition'; 2 | import { Fragment } from '../CompilerInterface'; 3 | 4 | 5 | export class TextFragment extends Fragment { 6 | constructor( 7 | position: ISourcePosition, 8 | private readonly text: string) { 9 | super(position); 10 | } 11 | 12 | public render(): string { 13 | return '" + __escape("" + (' + this.text + ')) + "'; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/fragments/wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment } from '../CompilerInterface'; 2 | 3 | export class WrapperBeforeFragment extends Fragment { 4 | 5 | render(ctx: Context): string { 6 | 7 | const paramsTypeDecl = `export type __Params = { 8 | ${ctx.params.map(p => ' ' + p.name + ': ' + p.type).join(',\n')} 9 | } ${ctx.extends.parentTemplate ? '& __ParentParams' : ''}`; 10 | 11 | const argParams = `__params${ctx.params.length === 0 ? '?' : ''}: __Params`; 12 | 13 | const sectionsType = `{ 14 | ${ctx.sections.names.map(n => ` '${n}'?: __Section;`).join('\n')} 15 | }`; 16 | const safeSectionsType = `{ 17 | ${ctx.sections.names.map(n => ` '${n}': __Section;`).join('\n')} 18 | }`; 19 | 20 | const argSections = `__childSections?: ${sectionsType}`; 21 | 22 | const paramsDereference = ctx.params.map(p => ` const ${p.name} = __params.${p.name};`).join('\n') 23 | 24 | const imports = ctx.imports.map(i => { 25 | return 'import ' + i + ';'; 26 | }) 27 | .join('\n'); 28 | 29 | // We need to use the parent's definition of a Section otherwis the typescript compiler chokes (as of tsc@2.4.1) 30 | const parentImport = ctx.extends.parentTemplate ? 31 | `import __parent, { __Params as __ParentParams } from ${ctx.extends.parentTemplate}` : 32 | ''; 33 | 34 | const sectionsExpr = ` 35 | let __safeChildSections: ${sectionsType} = __childSections || {}; 36 | __safeChildSections; 37 | const __sections: ${safeSectionsType} = { 38 | ${ctx.sections.names.map(n => ` "${n}": __safeSection(__safeChildSections["${n}"])`).join(',\n')} 39 | }; 40 | __sections;`; 41 | 42 | const sectionInterfaceDecl = 43 | `export interface __Section { 44 | (parent: () => string): () => string; 45 | }`; 46 | 47 | 48 | return (` 49 | /* tslint:disable */ 50 | // This file was generated from a topside template. 51 | // Do not edit this file, edit the original template instead. 52 | 53 | import * as __escape from 'escape-html'; 54 | __escape; 55 | ${imports} 56 | ${parentImport} 57 | ${sectionInterfaceDecl} 58 | ${paramsTypeDecl} 59 | 60 | function __identity(t: T): T { 61 | return t; 62 | } 63 | __identity; 64 | 65 | function __safeSection(section?: __Section): __Section { 66 | return section ? section : __identity; 67 | } 68 | __safeSection; 69 | 70 | export default function(${argParams}, ${argSections}): string { 71 | ${paramsDereference} 72 | ${sectionsExpr} 73 | const __text = "` 74 | ); 75 | } 76 | } 77 | 78 | export class WrapperAfterFragment extends Fragment { 79 | 80 | render(ctx: Context): string { 81 | ctx; 82 | return `"; 83 | __text; 84 | return ${ctx.extends.parentTemplate ? '__parent(__params, __sections)' : '__text'}; 85 | };`; 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /src/rules/comment.ts: -------------------------------------------------------------------------------- 1 | import { Token } from '../Token'; 2 | import NullFragment from '../fragments/null'; 3 | import { Context, Fragment, Rule } from '../CompilerInterface'; 4 | 5 | export const CommentRule: Rule = { 6 | name: "--", 7 | 8 | analyze(ctx: Context, t: Token): Fragment { 9 | ctx; 10 | return new NullFragment(t); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/rules/conditionals.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from '../CompilerInterface'; 2 | import { ElseFragment, ElseIfFragment, EndIfFragment, IfFragment } from '../fragments/conditionals'; 3 | import { Token } from '../Token'; 4 | 5 | declare module "../CompilerInterface" { 6 | interface Context { 7 | conditionals: { 8 | statementStack: Array<{ 9 | hasElse: boolean 10 | }> 11 | } 12 | } 13 | } 14 | 15 | 16 | export const IfRule: Rule = { 17 | name: "if", 18 | 19 | initContext(ctx: Context): void { 20 | ctx.conditionals = { 21 | statementStack: [] 22 | }; 23 | }, 24 | 25 | analyze(ctx: Context, t: Token): Fragment { 26 | ctx.conditionals.statementStack.splice(0, 0, { 27 | hasElse: false 28 | }); 29 | return new IfFragment(t, t.data); 30 | } 31 | }; 32 | 33 | export const ElseRule: Rule = { 34 | name: "else", 35 | 36 | analyze(ctx: Context, t: Token): Fragment { 37 | ctx.conditionals.statementStack[0].hasElse = true; 38 | return new ElseFragment(t); 39 | } 40 | }; 41 | 42 | export const ElseIfRule: Rule = { 43 | name: "elseif", 44 | 45 | analyze(_: Context, t: Token): Fragment { 46 | return new ElseIfFragment(t, t.data); 47 | } 48 | }; 49 | 50 | export const EndIfRule: Rule = { 51 | name: "endif", 52 | 53 | analyze(ctx: Context, t: Token): Fragment { 54 | // Skips the return statement if there is an `else` block to avoid generating unreachable code. 55 | const returnStatement = !ctx.conditionals.statementStack[0].hasElse; 56 | ctx.conditionals.statementStack.splice(0, 1); 57 | return new EndIfFragment(t, returnStatement); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/rules/extends.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from "../CompilerInterface"; 2 | import NullFragment from "../fragments/null"; 3 | import CompileError from "../CompileError"; 4 | import { Token } from "../Token"; 5 | 6 | declare module "../CompilerInterface" { 7 | interface Context { 8 | extends: { 9 | parentTemplate: string | null 10 | } 11 | } 12 | } 13 | 14 | export const ExtendsRule: Rule = { 15 | name: "extends", 16 | 17 | initContext(ctx: Context): void { 18 | ctx.extends = { 19 | parentTemplate: null 20 | }; 21 | }, 22 | 23 | analyze(ctx: Context, t: Token): Fragment { 24 | const data = t.data.trim(); 25 | if (data.length < 2 || data.charAt(0) !== data.charAt(data.length-1) || ['"', "'"].indexOf(data.charAt(0)) === -1) { 26 | console.error(data); 27 | let errorPos = t.ch + ExtendsRule.name.length + 2; 28 | throw new CompileError( 29 | "Invalid parent template name (be sure to use a quoted string)", 30 | t.line, 31 | errorPos 32 | ); 33 | } 34 | if (ctx.extends.parentTemplate) { 35 | throw new CompileError( 36 | "Duplicate 'extends' directives", 37 | t.line, 38 | t.ch + 2 39 | ); 40 | } 41 | ctx.extends.parentTemplate = data; 42 | return new NullFragment(t); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/rules/for.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from '../CompilerInterface'; 2 | import { BeginForFragment, EndForFragment } from '../fragments/for'; 3 | import { Token } from '../Token'; 4 | 5 | 6 | export const ForRule: Rule = { 7 | name: "for", 8 | 9 | analyze(ctx: Context, t: Token): Fragment { 10 | ctx; 11 | return new BeginForFragment(t, t.data); 12 | } 13 | }; 14 | 15 | export const EndForRule: Rule = { 16 | name: "endfor", 17 | 18 | analyze(ctx: Context, t: Token): Fragment { 19 | ctx; 20 | return new EndForFragment(t); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/rules/html.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from '../CompilerInterface'; 2 | import { HtmlFragment } from '../fragments/html'; 3 | import { Token } from '../Token'; 4 | 5 | export const HtmlRule: Rule = { 6 | name: "html", 7 | 8 | analyze(ctx: Context, t: Token): Fragment { 9 | ctx; 10 | return new HtmlFragment(t, t.data); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/rules/import.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from "../CompilerInterface"; 2 | import NullFragment from "../fragments/null"; 3 | import { Token } from "../Token"; 4 | 5 | const SEMICOLON_RX = /;$/; 6 | 7 | declare module "../CompilerInterface" { 8 | interface Context { 9 | imports: string[]; 10 | } 11 | } 12 | 13 | export const ImportRule: Rule = { 14 | name: "import", 15 | 16 | initContext(ctx: Context): void { 17 | ctx.imports = []; 18 | }, 19 | 20 | analyze(ctx: Context, t: Token): Fragment { 21 | const importExpr = t.data.trim().replace(SEMICOLON_RX, ""); 22 | ctx.imports.push(importExpr); 23 | return new NullFragment(t); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/rules/param.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from "../CompilerInterface"; 2 | import NullFragment from "../fragments/null"; 3 | import CompileError from "../CompileError"; 4 | import { Token } from "../Token"; 5 | 6 | const SEMICOLON_RX = /;$/; 7 | 8 | export interface TemplateParameter { 9 | name: string; 10 | type: string; 11 | } 12 | 13 | declare module "../CompilerInterface" { 14 | interface Context { 15 | params: TemplateParameter[] 16 | } 17 | } 18 | 19 | export const ParamRule: Rule = { 20 | name: "param", 21 | 22 | initContext(ctx: Context): void { 23 | ctx.params = []; 24 | }, 25 | 26 | analyze(ctx: Context, t: Token): Fragment { 27 | const parts = t.data.split(":"); 28 | if (parts.length === 1 || parts[1].trim().length === 0) { 29 | let errorPos = t.ch + ParamRule.name.length + 2; 30 | throw new CompileError( 31 | "Missing type annotation.", 32 | t.line, 33 | errorPos 34 | ); 35 | } 36 | if (parts[0].trim().indexOf('__') === 0) { 37 | throw new CompileError( 38 | "Parameter name cannot start with '__'", 39 | t.line, 40 | t.ch 41 | ); 42 | } 43 | ctx.params.push({ 44 | name: parts[0].trim(), 45 | type: parts[1].trim().replace(SEMICOLON_RX, "") 46 | }); 47 | return new NullFragment(t); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/rules/passthrough.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from '../CompilerInterface'; 2 | import { PassThroughFragment } from '../fragments/passthrough'; 3 | import { Token } from '../Token'; 4 | 5 | export const PassThroughRule: Rule = { 6 | name: "passthrough", 7 | 8 | analyze(ctx: Context, t: Token): Fragment { 9 | ctx; 10 | return new PassThroughFragment(t, t.data); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/rules/section.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BeginSectionFragment, 3 | EndSectionFragment, 4 | InlineSectionFragment, 5 | ParentFragment, 6 | ShowFragment, 7 | YieldFragment, 8 | } from '../fragments/section'; 9 | import { Context, Fragment, Rule } from "../CompilerInterface"; 10 | import { Token } from "../Token"; 11 | 12 | declare module "../CompilerInterface" { 13 | interface Context { 14 | sections: { 15 | names: string[]; 16 | } 17 | } 18 | } 19 | 20 | function cleanQuotes(str: string): string { 21 | str = str.trim(); 22 | const q = str.charAt(0); 23 | if (q === "'" || q === '"') { 24 | if (str.charAt(str.length - 1) === q) { 25 | return str.substr(1, str.length - 2); 26 | } else { 27 | throw new Error(`Invalid string literal: ${str}`) 28 | } 29 | } 30 | return str; 31 | } 32 | 33 | /** 34 | * Extracts the section name from the tag. 35 | * 36 | * In case the string is quoted, quotes are removed (to support usage à la blade). 37 | * 38 | * For simplicity, this just looks if there's a comma and uses everything before. 39 | * It is pretty intuitive when the at-rule is used without quotes as recommended 40 | * in the doc but counter-intuitively fails to parse @section('aa,bb'); 41 | * 42 | * @param str The input string as seen in the source 43 | */ 44 | function getCleanSectionName(str: string): string { 45 | return cleanQuotes(str.split(',')[0]); 46 | } 47 | 48 | /** 49 | * Extracts the section data from the tag. 50 | * 51 | * Users can use the shorthand 52 | * 53 | * @section(name, data) 54 | * 55 | * Which stands for 56 | * 57 | * @section(name)@(data)@endsection 58 | * 59 | * @param str The input string as seen in the source 60 | */ 61 | function getCleanSectionData(str: string): string | null { 62 | const commaIdx = str.indexOf(','); 63 | if (commaIdx === -1) { 64 | return null; 65 | } 66 | else { 67 | return str.substr(commaIdx + 1).trim(); 68 | } 69 | } 70 | 71 | export const SectionRule: Rule = { 72 | name: "section", 73 | 74 | initContext(ctx: Context): void { 75 | ctx.sections = { 76 | names: [ ] 77 | } 78 | }, 79 | 80 | 81 | 82 | analyze(ctx: Context, t: Token): Fragment { 83 | // TODO: support the form @section('name', 'data') 84 | const name = getCleanSectionName(t.data); 85 | if (ctx.sections.names.indexOf(name) === -1) { 86 | ctx.sections.names.push(name); 87 | } 88 | const data = getCleanSectionData(t.data); 89 | if (data != null) { 90 | return new InlineSectionFragment(t, name, data); 91 | } else { 92 | return new BeginSectionFragment(t, name); 93 | } 94 | } 95 | }; 96 | 97 | export const EndSectionRule: Rule = { 98 | name: "endsection", 99 | 100 | analyze(_ctx: Context, t: Token): Fragment { 101 | return new EndSectionFragment(t); 102 | } 103 | }; 104 | 105 | export const ParentRule: Rule = { 106 | name: "parent", 107 | 108 | analyze(_ctx: Context, t: Token): Fragment { 109 | return new ParentFragment(t); 110 | } 111 | }; 112 | 113 | export const YieldRule: Rule = { 114 | name: "yield", 115 | 116 | analyze(ctx: Context, t: Token): Fragment { 117 | const name = cleanQuotes(t.data); 118 | if (ctx.sections.names.indexOf(name) === -1) { 119 | ctx.sections.names.push(name); 120 | } 121 | return new YieldFragment(t, name); 122 | } 123 | }; 124 | 125 | export const ShowRule: Rule = { 126 | name: "show", 127 | 128 | analyze(_ctx: Context, t: Token): Fragment { 129 | return new ShowFragment(t); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/rules/text.ts: -------------------------------------------------------------------------------- 1 | import { Context, Fragment, Rule } from '../CompilerInterface'; 2 | import { TextFragment } from '../fragments/text'; 3 | import { Token } from '../Token'; 4 | 5 | 6 | export const TextRule: Rule = { 7 | name: "text", 8 | 9 | analyze(_ctx: Context, t: Token): Fragment { 10 | return new TextFragment(t, t.data); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/topside.ts: -------------------------------------------------------------------------------- 1 | // Import here Polyfills if needed. Recommended core-js (npm i -D core-js) 2 | 3 | import Compiler from './Compiler'; 4 | import { WrapperAfterFragment, WrapperBeforeFragment } from './fragments/wrapper'; 5 | import { ElseIfRule, ElseRule, EndIfRule, IfRule } from './rules/conditionals'; 6 | import { EndForRule, ForRule } from './rules/for'; 7 | import { HtmlRule } from './rules/html'; 8 | import { ImportRule } from './rules/import'; 9 | import { ParamRule } from './rules/param'; 10 | import { PassThroughRule } from './rules/passthrough'; 11 | import { TextRule } from './rules/text'; 12 | import { ExtendsRule } from './rules/extends'; 13 | import { CommentRule } from './rules/comment'; 14 | import { EndSectionRule, ParentRule, SectionRule, ShowRule, YieldRule } from './rules/section'; 15 | 16 | /** 17 | * Global preconfigured compiler 18 | */ 19 | export const compiler = new Compiler({ 20 | _text: PassThroughRule, 21 | _default: TextRule 22 | }); 23 | 24 | compiler.use(CommentRule); 25 | compiler.use(ElseIfRule); 26 | compiler.use(ElseRule); 27 | compiler.use(EndForRule); 28 | compiler.use(EndIfRule); 29 | compiler.use(ExtendsRule); 30 | compiler.use(ForRule); 31 | compiler.use(HtmlRule); 32 | compiler.use(IfRule); 33 | compiler.use(ImportRule); 34 | compiler.use(ParentRule); 35 | compiler.use(ParamRule); 36 | compiler.use(PassThroughRule); 37 | compiler.use(SectionRule); 38 | compiler.use(ShowRule); 39 | compiler.use(EndSectionRule); 40 | compiler.use(TextRule); 41 | compiler.use(YieldRule); 42 | 43 | compiler.addFragment("before", WrapperBeforeFragment); 44 | compiler.addFragment("after", WrapperAfterFragment); 45 | -------------------------------------------------------------------------------- /tools/run-spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { diffLines } from 'diff'; 3 | import * as fs from 'fs'; 4 | 5 | const { ls, exec, echo } = require('shelljs'); 6 | const colors = require("colors"); 7 | 8 | echo('Running specs...'); 9 | if (exec('bin/topside --outDir spec/output/views/ --baseDir spec/fixtures/templates spec/fixtures/templates/*.top.html').code) { 10 | echo(colors.red('Failed to compile templates.')); 11 | process.exit(1); 12 | } 13 | 14 | let status = 0; 15 | const ok_specs = ls('spec/tests/OK/*.ts'); 16 | 17 | for (let spec of ok_specs) { 18 | const parsed = path.parse(spec); 19 | process.stdout.write(`Running spec: ${spec}\t`); 20 | exec(`node_modules/.bin/ts-node -P spec/tsconfig.json ${spec} > spec/output/${parsed.name}.html`); 21 | const diff = do_diff(`spec/references/${parsed.name}.html`, `spec/output/${parsed.name}.html`); 22 | if (diff) { 23 | echo(colors.red('FAILED')); 24 | console.log(diff); 25 | status++; 26 | } else { 27 | echo(colors.green('OK')); 28 | } 29 | } 30 | 31 | const fail_specs = ls('spec/tests/ERR/*.ts'); 32 | 33 | for (let spec of fail_specs) { 34 | process.stdout.write(`Running spec: ${spec}\t`); 35 | if(exec(`node_modules/.bin/ts-node -P spec/tsconfig.json ${spec} 2>/dev/null >/dev/null`).code) { 36 | echo(colors.green('OK')); 37 | } else { 38 | echo(colors.red('FAILED')); 39 | status++; 40 | } 41 | } 42 | 43 | 44 | process.exit(status); 45 | 46 | function do_diff(expected: string, actual: string) { 47 | let diff = ''; 48 | diffLines( 49 | fs.readFileSync(expected).toString(), 50 | fs.readFileSync(actual).toString(), { 51 | ignoreWhitespace: false, 52 | newlineIsToken: true, 53 | ignoreCase: false 54 | } 55 | ).forEach((part) => { 56 | if(part.added) { 57 | diff += colors.green('+ ' + part.value) + '\n'; 58 | } else if (part.removed) { 59 | diff += colors.red('- ' + part.value) + '\n'; 60 | } 61 | }); 62 | return diff; 63 | } 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitThis": true, 16 | "noImplicitReturns": true, 17 | "noImplicitAny": true, 18 | "declarationDir": "dist/types", 19 | "outDir": "compiled", 20 | "typeRoots": [ 21 | "node_modules/@types" 22 | ] 23 | }, 24 | "include": [ 25 | "src/**/*.ts" 26 | ] 27 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "variable-name": [ 8 | "allow-leading-underscore" 9 | ], 10 | "no-unused-expression": [ 11 | "allow-fast-null-checks" 12 | ] 13 | } 14 | } --------------------------------------------------------------------------------