├── .nvmrc ├── .gitignore ├── .prettierignore ├── vsc-client ├── tsconfig.json ├── release.md ├── .vscode │ ├── tasks.json │ └── launch.json ├── README.md ├── package.json └── src │ └── extension.ts ├── language-server ├── tsconfig.json ├── package.json └── src │ └── index.ts ├── .husky └── pre-commit ├── e2e ├── src │ └── templates │ │ ├── helpers.ts │ │ ├── template-4.ets │ │ ├── template-1.ets │ │ ├── user-partial.ets │ │ ├── template-2.ets │ │ ├── template-4.test.ts │ │ ├── template-1.test.ts │ │ ├── index.ts │ │ ├── template-4.ets.ts │ │ ├── template-1.ets.ts │ │ ├── template-3.ets │ │ ├── template-2.test.ts │ │ ├── template-2.ets.ts │ │ ├── user-partial.ets.ts │ │ ├── template-5.ets │ │ ├── template-6.ets │ │ ├── template-5.test.ts │ │ ├── template-3.test.ts │ │ ├── template-3.ets.ts │ │ ├── template-5.ets.ts │ │ ├── template-6.ets.ts │ │ └── template-6.test.ts ├── ets.config.mjs ├── babel.config.cjs ├── package.json ├── README.md ├── jest.config.cjs └── package-lock.json ├── src ├── index.ts ├── compiler │ ├── test.ts │ ├── utils │ │ ├── index.ts │ │ └── test.ts │ ├── index.ts │ └── __snapshots__ │ │ └── test.ts.snap ├── parser │ ├── test.ts │ └── index.ts └── cli │ └── cli.ts ├── babel.config.cjs ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── tsconfig.json ├── jest.config.cjs ├── public.package.json ├── CHANGELOG.md ├── .eslintrc.cjs ├── CONTRIBUTING.md ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.vsix 3 | todo.txt 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.ets.ts 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /vsc-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /language-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint:fix 5 | -------------------------------------------------------------------------------- /e2e/src/templates/helpers.ts: -------------------------------------------------------------------------------- 1 | export function uppercase(input: string): string { 2 | return input.toUpperCase(); 3 | } 4 | -------------------------------------------------------------------------------- /e2e/ets.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('embedded-typescript').Config} */ 2 | export default { 3 | source: "./src/templates", 4 | }; 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { run } from "./cli/cli.js"; 3 | export type { UserConfig as Config } from "./cli/cli.js"; 4 | 5 | void run(); 6 | -------------------------------------------------------------------------------- /e2e/src/templates/template-4.ets: -------------------------------------------------------------------------------- 1 | --- 2 | import { uppercase } from './helpers'; 3 | 4 | export interface Props { 5 | name: string; 6 | } 7 | --- 8 | Hello <%= uppercase(props.name) %>! 9 | -------------------------------------------------------------------------------- /e2e/src/templates/template-1.ets: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | users: { name: string }[]; 4 | } 5 | --- 6 | <% props.users.forEach(function(user) { %> 7 | Name: <%= user.name %> 8 | <% }) %> 9 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | presets: [ 4 | ["@babel/preset-env", { targets: { node: "current" } }], 5 | "@babel/preset-typescript", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /e2e/babel.config.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | presets: [ 4 | ["@babel/preset-env", { targets: { node: "current" } }], 5 | "@babel/preset-typescript", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /e2e/src/templates/user-partial.ets: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | name: string; 4 | email: string; 5 | phone: string; 6 | } 7 | --- 8 | Name: <%= props.name %> 9 | Email: <%= props.email %> 10 | Phone: <%= props.phone %> 11 | -------------------------------------------------------------------------------- /e2e/src/templates/template-2.ets: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | name: string; 4 | needsPasswordReset: boolean; 5 | } 6 | --- 7 | Hello <%= props.name %>! 8 | <% if (props.needsPasswordReset) { %> 9 | You need to update your password. 10 | <% } %> 11 | -------------------------------------------------------------------------------- /e2e/src/templates/template-4.test.ts: -------------------------------------------------------------------------------- 1 | import render from "./template-4.ets"; 2 | 3 | describe("template-4", () => { 4 | it("renders OUTPUT1", () => { 5 | const input = { name: "Tate" }; 6 | expect(render(input)).toMatchInlineSnapshot(`"Hello TATE!"`); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ets/example", 3 | "description": "Embedded TypeScript example", 4 | "main": "./src/templates/index.ts", 5 | "scripts": { 6 | "build": "npx ets", 7 | "clean": "rm -rf src/**/*.ets.ts" 8 | }, 9 | "dependencies": { 10 | "embedded-typescript": "file:../dist" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /vsc-client/release.md: -------------------------------------------------------------------------------- 1 | ### Publishing 2 | 3 | https://code.visualstudio.com/api/working-with-extensions/bundling-extension 4 | https://code.visualstudio.com/api/working-with-extensions/publishing-extension 5 | https://marketplace.visualstudio.com/manage/publishers/embedded-typescript 6 | 7 | `yarn vsce package` 8 | `yarn vsce publish` 9 | -------------------------------------------------------------------------------- /vsc-client/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": "build", 8 | "presentation": { 9 | "panel": "dedicated", 10 | "reveal": "never" 11 | }, 12 | "problemMatcher": ["$tsc"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /e2e/src/templates/template-1.test.ts: -------------------------------------------------------------------------------- 1 | import render from "./template-1.ets"; 2 | 3 | describe("template-1", () => { 4 | it("renders expected output", () => { 5 | const input = { users: [{ name: "Tate" }, { name: "Emily" }] }; 6 | expect(render(input)).toMatchInlineSnapshot(` 7 | "Name: Tate 8 | Name: Emily 9 | " 10 | `); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /e2e/src/templates/index.ts: -------------------------------------------------------------------------------- 1 | export { default as template1 } from "./template-1.ets"; 2 | export { default as template2 } from "./template-2.ets"; 3 | export { default as template3 } from "./template-3.ets"; 4 | export { default as template4 } from "./template-4.ets"; 5 | export { default as template5 } from "./template-5.ets"; 6 | export { default as template6 } from "./template-6.ets"; 7 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # Embedded TypeScript Example 2 | 3 | An example project using Embedded TypeScript. 4 | 5 | The [.ets.json](https://github.com/tatethurston/embedded-typescript/blob/main/example/.ets.json) configures the compiler. The `*.ets.ts` files are generated by the compiler from the `*.ets` template files in `src/templates`. The corresponding `*${NAME}.test.ts` shows example usage and output. 6 | -------------------------------------------------------------------------------- /e2e/jest.config.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | clearMocks: true, 4 | coverageDirectory: "coverage", 5 | moduleNameMapper: { 6 | // necessary because jest's internal resolver does not follow the same import heuristics as TS 7 | // .ets.ts should be imported before searching for .ets when importing 'foo.ets' but jest does 8 | // the reverse order. 9 | "(.*).ets": "$1.ets.ts", 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | ci: 9 | name: "Lint and Test" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: "16.x" 16 | cache: "npm" 17 | - run: npm install 18 | - run: npm run lint 19 | - run: npm run e2e:setup 20 | - run: npm run test 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "isolatedModules": true, 6 | "module": "esnext", 7 | "moduleResolution": "Node", 8 | "noEmitOnError": false, 9 | "outDir": "dist", 10 | "resolveJsonModule": true, 11 | "rootDir": "src", 12 | "skipLibCheck": true, 13 | "sourceMap": false, 14 | "strict": true, 15 | "declaration": true, 16 | "target": "ES2020" 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: "16.x" 13 | cache: "npm" 14 | registry-url: "https://registry.npmjs.org" 15 | - run: npm run package:build 16 | - run: cd dist && npm publish 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | -------------------------------------------------------------------------------- /e2e/src/templates/template-4.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./template-4.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | import { uppercase } from "./helpers"; 10 | 11 | export interface Props { 12 | name: string; 13 | } 14 | 15 | export default function (props: Props): string { 16 | let result = ""; 17 | result += "Hello "; 18 | result += uppercase(props.name); 19 | result += "!"; 20 | return result; 21 | } 22 | -------------------------------------------------------------------------------- /e2e/src/templates/template-1.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./template-1.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | export interface Props { 10 | users: { name: string }[]; 11 | } 12 | 13 | export default function (props: Props): string { 14 | let result = ""; 15 | props.users.forEach(function (user) { 16 | result += "Name: "; 17 | result += user.name; 18 | result += "\n"; 19 | }); 20 | return result; 21 | } 22 | -------------------------------------------------------------------------------- /e2e/src/templates/template-3.ets: -------------------------------------------------------------------------------- 1 | --- 2 | type AccountType = 'user' | 'admin' | 'enterprise'; 3 | 4 | export interface Props { 5 | name: string; 6 | type: AccountType; 7 | } 8 | --- 9 | Hello <%= props.name %>, you are <% 10 | switch (props.type) { %> 11 | <% case 'user': { %> 12 | a user! 13 | <% break; } %> 14 | <% case 'admin': { %> 15 | an admin! 16 | <% break; } %> 17 | <% case 'enterprise': { %> 18 | an enterprise user! 19 | <% break; } %> 20 | <% default: { 21 | const exhaust: never = props.type; 22 | return exhaust; 23 | } %> 24 | <% } %> 25 | -------------------------------------------------------------------------------- /e2e/src/templates/template-2.test.ts: -------------------------------------------------------------------------------- 1 | import render from "./template-2.ets"; 2 | 3 | describe("template-2", () => { 4 | it("renders OUTPUT1", () => { 5 | const input = { name: "Tate", needsPasswordReset: false }; 6 | expect(render(input)).toMatchInlineSnapshot(` 7 | "Hello Tate! 8 | " 9 | `); 10 | }); 11 | 12 | it("renders OUTPUT2", () => { 13 | const input = { name: "Tate", needsPasswordReset: true }; 14 | expect(render(input)).toMatchInlineSnapshot(` 15 | "Hello Tate! 16 | You need to update your password. 17 | " 18 | `); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /e2e/src/templates/template-2.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./template-2.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | export interface Props { 10 | name: string; 11 | needsPasswordReset: boolean; 12 | } 13 | 14 | export default function (props: Props): string { 15 | let result = ""; 16 | result += "Hello "; 17 | result += props.name; 18 | result += "!\n"; 19 | if (props.needsPasswordReset) { 20 | result += "You need to update your password.\n"; 21 | } 22 | return result; 23 | } 24 | -------------------------------------------------------------------------------- /e2e/src/templates/user-partial.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./user-partial.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | export interface Props { 10 | name: string; 11 | email: string; 12 | phone: string; 13 | } 14 | 15 | export default function (props: Props): string { 16 | let result = ""; 17 | result += "Name: "; 18 | result += props.name; 19 | result += "\nEmail: "; 20 | result += props.email; 21 | result += "\nPhone: "; 22 | result += props.phone; 23 | return result; 24 | } 25 | -------------------------------------------------------------------------------- /src/compiler/test.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { readdirSync, readFileSync } from "fs"; 3 | import { compiler } from "./index.js"; 4 | 5 | const templatePath = "../../e2e/src/templates/"; 6 | 7 | const templates = readdirSync(join(__dirname, templatePath)).filter( 8 | (filename) => filename.endsWith(".ets") 9 | ); 10 | 11 | describe(compiler, () => { 12 | it.each(templates)("compile(%s)", (filename) => { 13 | const template = readFileSync( 14 | join(__dirname, templatePath, filename), 15 | "utf8" 16 | ); 17 | expect(compiler(template, filename)).toMatchSnapshot(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /e2e/src/templates/template-5.ets: -------------------------------------------------------------------------------- 1 | --- 2 | type AccountType = 'user' | 'admin' | 'enterprise'; 3 | 4 | export interface Props { 5 | name: string; 6 | type: AccountType; 7 | } 8 | --- 9 | <% 10 | let userMessage; 11 | switch (props.type) { 12 | case 'user': { 13 | userMessage = 'a user!'; 14 | break; 15 | } 16 | case 'admin': { 17 | userMessage = 'an admin!'; 18 | break; 19 | } 20 | case 'enterprise': { 21 | userMessage = 'an enterprise user!'; 22 | break; 23 | } 24 | default: { 25 | const exhaust: never = props.type; 26 | return exhaust; 27 | } 28 | } 29 | %> 30 | Hello <%= props.name %>, you are <%= userMessage %> 31 | -------------------------------------------------------------------------------- /vsc-client/README.md: -------------------------------------------------------------------------------- 1 | # Embedded TypeScript 2 | 3 |
Type safe embedded TypeScript templates
4 | 5 | ## What is this? 🧐 6 | 7 | The Visual Studio Code client for the [Embedded TypeScript](https://github.com/tatethurston/embedded-typescript) Language Server. 8 | 9 | ## Highlights 10 | 11 | 🎁 Zero run time dependencies 12 | 13 | ## Contributing 👫 14 | 15 | PR's and issues welcomed! For more guidance check out [CONTRIBUTING.md](https://github.com/tatethurston/embedded-typescript/blob/master/CONTRIBUTING.md) 16 | 17 | ## Licensing 📃 18 | 19 | See the project's [MIT License](https://github.com/tatethurston/embedded-typescript/blob/master/LICENSE). 20 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | clearMocks: true, 4 | coverageDirectory: "coverage", 5 | modulePathIgnorePatterns: ["dist", "examples"], 6 | moduleNameMapper: { 7 | // TS ESM imports are referenced with .js extensions, but jest will fail to find 8 | // the uncompiled file because it ends with .ts and is looking for .js. 9 | "(.+)\\.jsx?": "$1", 10 | // necessary because jest's internal resolver does not follow the same import heuristics as TS 11 | // .ets.ts should be imported before searching for .ets when importing 'foo.ets' but jest does 12 | // the reverse order. 13 | "(.*).ets": "$1.ets.ts", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /e2e/src/templates/template-6.ets: -------------------------------------------------------------------------------- 1 | --- 2 | import renderUser, { Props as User } from './user-partial.ets'; 3 | 4 | export interface Props { 5 | users: User[]; 6 | } 7 | 8 | const example = 9 | `1 10 | 2 11 | 3 12 | 4`; 13 | --- 14 | <% if (props.users.length > 0) { %> 15 | Here is a list of users: 16 | <% props.users.forEach(function(user) { %> 17 | 18 | <%= renderUser(user) %> 19 | <% }) %> 20 | 21 | <% } %> 22 | The indentation level is preserved for the rendered 'partial'. 23 | 24 | There isn't anything special about the 'partial'. Here we used another ets template, but any 25 | expression yeilding a multiline string would be treated the same. 26 | 27 | <%= example %> 28 | 29 | The end! 30 | -------------------------------------------------------------------------------- /public.package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "embedded-typescript", 3 | "version": "0.1.0", 4 | "description": "Type safe TypeScript templates", 5 | "license": "MIT", 6 | "author": "Tate ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/tatethurston/embedded-typescript" 10 | }, 11 | "type": "module", 12 | "bin": { 13 | "ets": "./index.js" 14 | }, 15 | "dependencies": { 16 | "prettier": "^2.7.1" 17 | }, 18 | "keywords": [ 19 | "embedded typescript", 20 | "ets", 21 | "type safe string", 22 | "type safe template", 23 | "type safe templating", 24 | "typescript template", 25 | "typescript templating" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /e2e/src/templates/template-5.test.ts: -------------------------------------------------------------------------------- 1 | import render from "./template-5.ets"; 2 | 3 | describe("template-5", () => { 4 | it("renders OUTPUT1", () => { 5 | const input = { name: "Tate", type: "user" as const }; 6 | expect(render(input)).toMatchInlineSnapshot( 7 | `"Hello Tate, you are a user!"` 8 | ); 9 | }); 10 | 11 | it("renders OUTPUT2", () => { 12 | const input = { name: "Tate", type: "admin" as const }; 13 | expect(render(input)).toMatchInlineSnapshot( 14 | `"Hello Tate, you are an admin!"` 15 | ); 16 | }); 17 | 18 | it("renders OUTPUT3", () => { 19 | const input = { name: "Tate", type: "enterprise" as const }; 20 | expect(render(input)).toMatchInlineSnapshot( 21 | `"Hello Tate, you are an enterprise user!"` 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 4 | 5 | This is a rewrite of `embedded-typescript` and a major breaking change. 6 | 7 | `embedded-typescript` now generates a single function per `.ets` template. This significantly cuts down on the syntax noise and improves ergonomics for the common use case. 8 | 9 | Previously: 10 | 11 | // users.ets 12 | 13 | ``` 14 | interface User { 15 | name: string; 16 | } 17 | 18 | export function render(users: User[]): string { 19 | return <%> 20 | <% users.forEach(function(user) { %> 21 | Name: <%= user.name %> 22 | <% }) %> 23 | <%> 24 | } 25 | ``` 26 | 27 | Now: 28 | 29 | // users.ets 30 | 31 | ``` 32 | --- 33 | interface Props { 34 | users: { name: string }[]; 35 | } 36 | --- 37 | <% props.users.forEach(function(user) { %> 38 | Name: <%= user.name %> 39 | <% }) %> 40 | ``` 41 | -------------------------------------------------------------------------------- /e2e/src/templates/template-3.test.ts: -------------------------------------------------------------------------------- 1 | import render from "./template-3.ets"; 2 | 3 | describe("template-3", () => { 4 | it("renders OUTPUT1", () => { 5 | const input = { name: "Tate", type: "user" as const }; 6 | expect(render(input)).toMatchInlineSnapshot(` 7 | "Hello Tate, you are a user! 8 | " 9 | `); 10 | }); 11 | 12 | it("renders OUTPUT2", () => { 13 | const input = { name: "Tate", type: "admin" as const }; 14 | expect(render(input)).toMatchInlineSnapshot(` 15 | "Hello Tate, you are an admin! 16 | " 17 | `); 18 | }); 19 | 20 | it("renders OUTPUT3", () => { 21 | const input = { name: "Tate", type: "enterprise" as const }; 22 | expect(render(input)).toMatchInlineSnapshot(` 23 | "Hello Tate, you are an enterprise user! 24 | " 25 | `); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /e2e/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ets/example", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "@ets/example", 8 | "dependencies": { 9 | "embedded-typescript": "file:../dist" 10 | } 11 | }, 12 | "../dist": { 13 | "name": "embedded-typescript", 14 | "version": "0.1.0", 15 | "license": "MIT", 16 | "dependencies": { 17 | "prettier": "^2.7.1" 18 | }, 19 | "bin": { 20 | "ets": "index.js" 21 | } 22 | }, 23 | "node_modules/embedded-typescript": { 24 | "resolved": "../dist", 25 | "link": true 26 | } 27 | }, 28 | "dependencies": { 29 | "embedded-typescript": { 30 | "version": "file:../dist", 31 | "requires": { 32 | "prettier": "^2.7.1" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /vsc-client/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "extensionHost", 6 | "request": "launch", 7 | "name": "Launch Client", 8 | "runtimeExecutable": "${execPath}", 9 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 10 | "outFiles": ["${workspaceRoot}/dist/extension.js"], 11 | "preLaunchTask": { 12 | "type": "npm", 13 | "script": "build" 14 | } 15 | }, 16 | { 17 | "type": "node", 18 | "request": "attach", 19 | "name": "Attach to Server", 20 | "port": 6009, 21 | "restart": true, 22 | "outFiles": ["${workspaceRoot}/dist/server.js"] 23 | } 24 | ], 25 | "compounds": [ 26 | { 27 | "name": "Client + Server", 28 | "configurations": ["Launch Client", "Attach to Server"] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /language-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ets/language-server", 3 | "version": "0.0.7", 4 | "description": "Embedded TypeScript Language Server", 5 | "license": "MIT", 6 | "author": "Tate ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/tatethurston/embedded-typescript" 10 | }, 11 | "files": [ 12 | "dist/index.js" 13 | ], 14 | "scripts": { 15 | "build": "esbuild src/index.ts --platform=node --target=node12 --bundle --outfile=dist/index.js", 16 | "clean": "rm -rf dist", 17 | "version": "yarn run build && git add -A package.json", 18 | "postversion": "git push && git push --tags" 19 | }, 20 | "dependencies": { 21 | "@ets/parser": "0.0.7", 22 | "vscode-languageserver": "^7.0.0", 23 | "vscode-languageserver-textdocument": "^1.0.1" 24 | }, 25 | "devDependencies": { 26 | "esbuild": "^0.11.20" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | root: true, 4 | ignorePatterns: ["dist", "e2e", "coverage", "language-server", "vsc-client"], 5 | plugins: ["@typescript-eslint"], 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier", 10 | ], 11 | overrides: [ 12 | { 13 | files: ["*.ts"], 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | // eslint-disable-next-line no-undef 17 | tsconfigRootDir: __dirname, 18 | project: ["./tsconfig.json"], 19 | }, 20 | extends: [ 21 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 22 | ], 23 | rules: { 24 | "@typescript-eslint/prefer-nullish-coalescing": "error", 25 | "@typescript-eslint/no-unnecessary-condition": "error", 26 | "@typescript-eslint/prefer-optional-chain": "error", 27 | }, 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /e2e/src/templates/template-3.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./template-3.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | type AccountType = "user" | "admin" | "enterprise"; 10 | 11 | export interface Props { 12 | name: string; 13 | type: AccountType; 14 | } 15 | 16 | export default function (props: Props): string { 17 | let result = ""; 18 | result += "Hello "; 19 | result += props.name; 20 | result += ", you are "; 21 | switch (props.type) { 22 | case "user": { 23 | result += "a user!\n"; 24 | break; 25 | } 26 | case "admin": { 27 | result += "an admin!\n"; 28 | break; 29 | } 30 | case "enterprise": { 31 | result += "an enterprise user!\n"; 32 | break; 33 | } 34 | default: { 35 | const exhaust: never = props.type; 36 | return exhaust; 37 | } 38 | } 39 | return result; 40 | } 41 | -------------------------------------------------------------------------------- /e2e/src/templates/template-5.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./template-5.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | type AccountType = "user" | "admin" | "enterprise"; 10 | 11 | export interface Props { 12 | name: string; 13 | type: AccountType; 14 | } 15 | 16 | export default function (props: Props): string { 17 | let result = ""; 18 | let userMessage; 19 | switch (props.type) { 20 | case "user": { 21 | userMessage = "a user!"; 22 | break; 23 | } 24 | case "admin": { 25 | userMessage = "an admin!"; 26 | break; 27 | } 28 | case "enterprise": { 29 | userMessage = "an enterprise user!"; 30 | break; 31 | } 32 | default: { 33 | const exhaust: never = props.type; 34 | return exhaust; 35 | } 36 | } 37 | result += "Hello "; 38 | result += props.name; 39 | result += ", you are "; 40 | result += userMessage; 41 | return result; 42 | } 43 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 👫 2 | 3 | Thanks for helping make this project better! 4 | 5 | ## Report an Issue 🐛 6 | 7 | If you find a bug or want to discuss a new feature, please [create a new issue](https://github.com/tatethurston/embedded-typescript/issues). If you'd prefer to keep things private, feel free to [email me](mailto:tatethurston@gmail.com?subject=embedded-typescript). 8 | 9 | ## Contributing Code with Pull Requests 🎁 10 | 11 | Please create a [pull request](https://github.com/tatethurston/embedded-typescript/pulls). Expect a few iterations and some discussion before your pull request is merged. If you want to take things in a new direction, feel free to fork and iterate without hindrance! 12 | 13 | ## Code of Conduct 🧐 14 | 15 | My expectations for myself and others is to strive to build a diverse, inclusive, safe community. 16 | 17 | For more guidance, check out [thoughtbot's code of conduct](https://thoughtbot.com/open-source-code-of-conduct). 18 | 19 | ## Licensing 📃 20 | 21 | See the project's [MIT License](https://github.com/tatethurston/embedded-typescript/blob/master/LICENSE). 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tate Thurston 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /e2e/src/templates/template-6.ets.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 3 | * 4 | * Run `npx ets` or `yarn ets` to regenerate this file. 5 | * Source: ./template-6.ets 6 | */ 7 | /* eslint-disable */ 8 | 9 | import renderUser, { Props as User } from "./user-partial.ets"; 10 | 11 | export interface Props { 12 | users: User[]; 13 | } 14 | 15 | const example = `1 16 | 2 17 | 3 18 | 4`; 19 | 20 | export default function (props: Props): string { 21 | let result = ""; 22 | if (props.users.length > 0) { 23 | result += "Here is a list of users:\n"; 24 | props.users.forEach(function (user) { 25 | result += "\n "; 26 | result += preserveIndentation(renderUser(user), " "); 27 | result += "\n"; 28 | }); 29 | result += "\n"; 30 | } 31 | result += 32 | "The indentation level is preserved for the rendered 'partial'.\n\nThere isn't anything special about the 'partial'. Here we used another ets template, but any\nexpression yeilding a multiline string would be treated the same.\n\n "; 33 | result += preserveIndentation(example, " "); 34 | result += "\n\nThe end!"; 35 | return result; 36 | } 37 | 38 | function preserveIndentation(text: string, indentation: string): string { 39 | return text 40 | .split("\n") 41 | .map((line, idx) => (idx === 0 ? line : indentation + line)) 42 | .join("\n"); 43 | } 44 | -------------------------------------------------------------------------------- /vsc-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "embedded-typescript-vsc", 4 | "version": "0.0.7", 5 | "description": "Embedded TypeScript Language Server Client for Visual Studio Code", 6 | "license": "MIT", 7 | "author": "Tate ", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/tatethurston/embedded-typescript" 11 | }, 12 | "main": "./dist/extension", 13 | "scripts": { 14 | "build": "yarn build:client && yarn build:server", 15 | "build:client": "esbuild src/extension.ts --external:'vscode' --platform=node --target=node12 --bundle --outfile=dist/extension.js", 16 | "build:server": "(cd ../language-server && yarn build && mv dist/{index,server}.js) && cp ../language-server/dist/server.js ./dist/", 17 | "vsce:publish": "yarn build && yarn vsce package && yarn vsce publish" 18 | }, 19 | "devDependencies": { 20 | "@types/vscode": "^1.52.0", 21 | "vsce": "^1.88.0", 22 | "vscode-languageclient": "^7.0.0", 23 | "vscode-test": "^1.3.0" 24 | }, 25 | "engines": { 26 | "vscode": "^1.52.0" 27 | }, 28 | "activationEvents": [ 29 | "onLanguage:embedded-typescript" 30 | ], 31 | "contributes": { 32 | "languages": [ 33 | { 34 | "id": "embedded-typescript", 35 | "extensions": [ 36 | ".ets" 37 | ] 38 | } 39 | ] 40 | }, 41 | "displayName": "Embedded TypeScript Language Server", 42 | "publisher": "embedded-typescript" 43 | } 44 | -------------------------------------------------------------------------------- /e2e/src/templates/template-6.test.ts: -------------------------------------------------------------------------------- 1 | import render from "./template-6.ets"; 2 | 3 | describe("template-6", () => { 4 | it("renders OUTPUT1", () => { 5 | const input = { 6 | users: [ 7 | { name: "Tate", phone: "888-888-8888", email: "tate@tate.com" }, 8 | { name: "Emily", phone: "777-777-7777", email: "emily@emily.com" }, 9 | ], 10 | }; 11 | expect(render(input)).toMatchInlineSnapshot(` 12 | "Here is a list of users: 13 | 14 | Name: Tate 15 | Email: tate@tate.com 16 | Phone: 888-888-8888 17 | 18 | Name: Emily 19 | Email: emily@emily.com 20 | Phone: 777-777-7777 21 | 22 | The indentation level is preserved for the rendered 'partial'. 23 | 24 | There isn't anything special about the 'partial'. Here we used another ets template, but any 25 | expression yeilding a multiline string would be treated the same. 26 | 27 | 1 28 | 2 29 | 3 30 | 4 31 | 32 | The end!" 33 | `); 34 | }); 35 | 36 | it("renders OUTPUT2", () => { 37 | expect(render({ users: [] })).toMatchInlineSnapshot(` 38 | "The indentation level is preserved for the rendered 'partial'. 39 | 40 | There isn't anything special about the 'partial'. Here we used another ets template, but any 41 | expression yeilding a multiline string would be treated the same. 42 | 43 | 1 44 | 2 45 | 3 46 | 4 47 | 48 | The end!" 49 | `); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build:module": "tsc", 5 | "clean": "rm -rf dist", 6 | "e2e:setup": "npm run package:build && (cd e2e && npm install && npm run build)", 7 | "lint": "npm run typecheck && prettier --check . && prettier-package-json --list-different '{,public.,example/}package.json' && eslint .", 8 | "lint:fix": "prettier --write . && prettier-package-json --write '{,public.,example/}package.json' && eslint --fix .", 9 | "package:build": "npm install && npm run clean && npm run build:module && npm run package:prune && npm run package:copy:files && chmod +x dist/index.js", 10 | "package:copy:files": "cp ./LICENSE ./README.md dist/ && cp ./public.package.json dist/package.json", 11 | "package:prune": "find dist -name *.test.* | xargs rm -f", 12 | "prepare": "husky install", 13 | "test": "jest", 14 | "test:ci": "test --coverage", 15 | "typecheck": "tsc --noEmit" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.19.1", 19 | "@babel/preset-env": "^7.19.1", 20 | "@babel/preset-typescript": "^7.18.6", 21 | "@types/jest": "^29.0.3", 22 | "@types/node": "^18.7.18", 23 | "@typescript-eslint/eslint-plugin": "^5.38.0", 24 | "@typescript-eslint/parser": "^5.38.0", 25 | "babel-jest": "^29.0.3", 26 | "eslint": "^8.23.1", 27 | "eslint-config-prettier": "^8.5.0", 28 | "husky": "^8.0.1", 29 | "jest": "^29.0.3", 30 | "prettier": "^2.7.1", 31 | "prettier-package-json": "^2.6.4", 32 | "typescript": "^4.8.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /vsc-client/src/extension.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { workspace, ExtensionContext } from "vscode"; 3 | 4 | import { 5 | LanguageClient, 6 | LanguageClientOptions, 7 | ServerOptions, 8 | TransportKind, 9 | } from "vscode-languageclient/node"; 10 | 11 | let client: LanguageClient | undefined; 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 | export function activate(context: ExtensionContext): void { 15 | const server = context.asAbsolutePath(join("dist", "server.js")); 16 | 17 | const serverOptions: ServerOptions = { 18 | run: { module: server, transport: TransportKind.ipc }, 19 | debug: { 20 | module: server, 21 | transport: TransportKind.ipc, 22 | options: { execArgv: ["--nolazy", "--inspect=6009"] }, 23 | }, 24 | }; 25 | 26 | // Options to control the language client 27 | const clientOptions: LanguageClientOptions = { 28 | // Register the server for plain text documents 29 | documentSelector: [{ scheme: "file", language: "embedded-typescript" }], 30 | synchronize: { 31 | // Notify the server about file changes to '.clientrc files contained in the workspace 32 | fileEvents: workspace.createFileSystemWatcher("**/.clientrc"), 33 | }, 34 | }; 35 | 36 | // Create the language client and start the client. 37 | client = new LanguageClient( 38 | "ETSLanguageServer", 39 | "Embedded TypeScript Language Server", 40 | serverOptions, 41 | clientOptions 42 | ); 43 | 44 | // Start the client. This will also launch the server 45 | client.start(); 46 | } 47 | 48 | export function deactivate(): Promise | undefined { 49 | return client?.stop(); 50 | } 51 | -------------------------------------------------------------------------------- /src/compiler/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Escape for safe inclusion in a single (') quoted string. 3 | */ 4 | export function sanitizeString(token: string): string { 5 | return token 6 | .replace(/\\/g, "\\\\") 7 | .replace(/'/g, "\\'") 8 | .replace(/\n/g, "\\n"); 9 | } 10 | 11 | const INDENTATION_TO_END_LINE_0 = /^[^\S\n]+$/; 12 | const INDENTATION_TO_END_LINE_N = /\n([^\S\n]*)$/; 13 | const START_TO_LINE_BREAK = /^[^\S\n]*\n/; 14 | 15 | /** 16 | * Returns the preceding indentation. 17 | * ___<% %> 18 | */ 19 | export function getLeadingIndentation(token: string): string { 20 | // if it's the first line 21 | return ( 22 | // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec 23 | token.match(INDENTATION_TO_END_LINE_0)?.[0] ?? 24 | // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec 25 | token.match(INDENTATION_TO_END_LINE_N)?.[1] ?? 26 | "" 27 | ); 28 | } 29 | 30 | /** 31 | * Trims the preceding indentation. 32 | * ___<% %> 33 | */ 34 | export function trimLeadingIndentation(token: string): string { 35 | // if it's the first line 36 | // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec 37 | if (token.match(INDENTATION_TO_END_LINE_0)) { 38 | return token.replace(INDENTATION_TO_END_LINE_0, ""); 39 | } 40 | return token.replace(INDENTATION_TO_END_LINE_N, "\n"); 41 | } 42 | 43 | /** 44 | * Trims the following line break and any whitespace. 45 | * 46 | * <% %>___ 47 | */ 48 | export function trimLaggingNewline(token: string): string { 49 | return token.replace(START_TO_LINE_BREAK, ""); 50 | } 51 | 52 | /** 53 | * Trims the preceding line break and any whitespace. 54 | * _ 55 | * __<%> 56 | */ 57 | export function trimLeadingIndentationAndNewline(token: string): string { 58 | // if it's the first line 59 | // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec 60 | if (token.match(INDENTATION_TO_END_LINE_0)) { 61 | return token.replace(INDENTATION_TO_END_LINE_0, ""); 62 | } 63 | return token.replace(INDENTATION_TO_END_LINE_N, ""); 64 | } 65 | 66 | export function removeFinalNewline(token: string): string { 67 | return token.replace(/\n$/, ""); 68 | } 69 | -------------------------------------------------------------------------------- /src/compiler/utils/test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sanitizeString, 3 | trimLeadingIndentation, 4 | trimLaggingNewline, 5 | getLeadingIndentation, 6 | trimLeadingIndentationAndNewline, 7 | } from "./index.js"; 8 | 9 | describe(sanitizeString, () => { 10 | it.each([ 11 | ["'hello'", "\\'hello\\'"], 12 | ["don't", "don\\'t"], 13 | [ 14 | `this 15 | is`, 16 | "this\\nis", 17 | ], 18 | ])("sanitizeString(%s)", (input, expected) => { 19 | expect(sanitizeString(input)).toEqual(expected); 20 | }); 21 | }); 22 | 23 | describe(getLeadingIndentation, () => { 24 | it.each([ 25 | ["t", ""], 26 | [" t", ""], 27 | ["t ", ""], 28 | [" ", " "], 29 | [ 30 | ` 31 | `, 32 | " ", 33 | ], 34 | ["\t ", "\t "], 35 | ["\n\n ", " "], 36 | ["\n\n\n", ""], 37 | ["\n\t ", "\t "], 38 | ])("getLeadingIndentation(%s)", (input, expected) => { 39 | expect(getLeadingIndentation(input)).toEqual(expected); 40 | }); 41 | }); 42 | 43 | describe(trimLeadingIndentation, () => { 44 | it.each([ 45 | ["t", "t"], 46 | [" t", " t"], 47 | ["t ", "t "], 48 | [" ", ""], 49 | [ 50 | ` 51 | `, 52 | "\n", 53 | ], 54 | ["\t ", ""], 55 | ["\n\n ", "\n\n"], 56 | ["\n\n\n", "\n\n\n"], 57 | ["\n\t ", "\n"], 58 | ])("trimLeadingIndentation(%s)", (input, expected) => { 59 | expect(trimLeadingIndentation(input)).toEqual(expected); 60 | }); 61 | }); 62 | 63 | describe(trimLaggingNewline, () => { 64 | it.each([ 65 | ["t", "t"], 66 | [" t", " t"], 67 | [" \n", ""], 68 | [" \n\n", "\n"], 69 | ])("trimLaggingNewline(%s)", (input, expected) => { 70 | expect(trimLaggingNewline(input)).toEqual(expected); 71 | }); 72 | }); 73 | 74 | describe(trimLeadingIndentationAndNewline, () => { 75 | it.each([ 76 | ["t", "t"], 77 | [" t", " t"], 78 | ["t ", "t "], 79 | [" ", ""], 80 | [ 81 | ` 82 | `, 83 | "", 84 | ], 85 | ["\t ", ""], 86 | ["\n\n ", "\n"], 87 | ["\n\n\n", "\n\n"], 88 | ["\n\t ", ""], 89 | ["\n", ""], 90 | ])("trimLeadingIndentationAndNewline(%s)", (input, expected) => { 91 | expect(trimLeadingIndentationAndNewline(input)).toEqual(expected); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/parser/test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "./index.js"; 2 | 3 | describe(parse, () => { 4 | describe("errors", () => { 5 | it("closing tag before open tag", () => { 6 | expect( 7 | parse(`\ 8 | --- 9 | type Props = { name: string; } 10 | --- 11 | %> 12 | <% users.forEach(function(user) { %> 13 | Name: <%= user.name %> 14 | <% }) %>\ 15 | `) 16 | ).toMatchInlineSnapshot(` 17 | { 18 | "context": " | 19 | 4 | %> 20 | | ^ 21 | | | 22 | ... 23 | ", 24 | "error": "Unexpected closing tag '%>'", 25 | "position": { 26 | "end": { 27 | "column": 2, 28 | "line": 4, 29 | }, 30 | "start": { 31 | "column": 1, 32 | "line": 4, 33 | }, 34 | }, 35 | } 36 | `); 37 | }); 38 | 39 | it("missing ending closing tag", () => { 40 | expect( 41 | parse(`\ 42 | --- 43 | type Props = { name: string; } 44 | --- 45 | <% users.forEach(function(user) { %> 46 | Name: <%= user.name %> 47 | <% })`) 48 | ).toMatchInlineSnapshot(` 49 | { 50 | "context": " | 51 | 6 | <% }) 52 | | ^ 53 | | | 54 | 55 | ", 56 | "error": "Expected to find corresponding closing tag '%>' before end of template", 57 | "position": { 58 | "end": { 59 | "column": 5, 60 | "line": 6, 61 | }, 62 | "start": { 63 | "column": 1, 64 | "line": 6, 65 | }, 66 | }, 67 | } 68 | `); 69 | }); 70 | 71 | it("extra opening tag", () => { 72 | expect( 73 | parse(`\ 74 | --- 75 | type Props = { name: string; } 76 | --- 77 | <% <% users.forEach(function(user) { %> 78 | Name: <%= user.name %> 79 | <% }) %>`) 80 | ).toMatchInlineSnapshot(` 81 | { 82 | "context": " | 83 | 4 | <% <% users.forEach(function(user) { %> 84 | | ^ 85 | | | 86 | ... 87 | ", 88 | "error": "Unexpected opening tag '<%'", 89 | "position": { 90 | "end": { 91 | "column": 5, 92 | "line": 4, 93 | }, 94 | "start": { 95 | "column": 4, 96 | "line": 4, 97 | }, 98 | }, 99 | } 100 | `); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/compiler/index.ts: -------------------------------------------------------------------------------- 1 | import { format } from "prettier"; 2 | import { 3 | trimLeadingIndentation, 4 | getLeadingIndentation, 5 | trimLaggingNewline, 6 | sanitizeString, 7 | removeFinalNewline, 8 | } from "./utils/index.js"; 9 | import { Node, parse, ParseError, isParseError } from "../parser/index.js"; 10 | 11 | const RESULT = "result"; 12 | 13 | function compile(nodes: Node[]): string { 14 | let compiled = ""; 15 | let indent = ""; 16 | let hasPreserveIndentation = false; 17 | 18 | function write(text: string): void { 19 | compiled += indent + text; 20 | } 21 | 22 | nodes.forEach((node, idx) => { 23 | const prevNode = nodes[idx - 1]; 24 | const nextNode = nodes[idx + 1]; 25 | 26 | switch (node.type) { 27 | case "header": { 28 | const props = /(interface|type) Props/.test(node.content) 29 | ? "Props" 30 | : "unknown"; 31 | write(`${node.content}\n\n`); 32 | write(`export default function (props: ${props}): string {\n`); 33 | indent += " "; 34 | write(`let ${RESULT} = '';\n`); 35 | break; 36 | } 37 | case "text": { 38 | let content = node.content; 39 | 40 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 41 | if (prevNode?.type === "statement" || prevNode?.type === "header") { 42 | content = trimLaggingNewline(content); 43 | } 44 | 45 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 46 | if (nextNode?.type === "statement") { 47 | content = trimLeadingIndentation(content); 48 | } 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 51 | if (!nextNode) { 52 | content = removeFinalNewline(content); 53 | } 54 | 55 | if (content) { 56 | write(`${RESULT} += '${sanitizeString(content)}';\n`); 57 | } 58 | break; 59 | } 60 | case "expression": { 61 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 62 | const indentation = getLeadingIndentation(prevNode.content ?? ""); 63 | if (!indentation) { 64 | write(`${RESULT} += ${node.content};\n`); 65 | } else { 66 | hasPreserveIndentation = true; 67 | write( 68 | `${RESULT} += preserveIndentation(${node.content}, '${indentation}');\n` 69 | ); 70 | } 71 | break; 72 | } 73 | case "statement": { 74 | write(`${node.content}\n`); 75 | break; 76 | } 77 | default: { 78 | const exhaust: never = node.type; 79 | return exhaust; 80 | } 81 | } 82 | }); 83 | 84 | write(`return ${RESULT};\n`); 85 | indent = ""; 86 | write(`}\n`); 87 | 88 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 89 | if (hasPreserveIndentation) { 90 | write(`\nfunction preserveIndentation(text: string, indentation: string): string { 91 | return text 92 | .split("\\n") 93 | .map((line, idx) => (idx === 0 ? line : indentation + line)) 94 | .join("\\n"); 95 | }`); 96 | } 97 | 98 | return compiled; 99 | } 100 | 101 | export function compiler( 102 | template: string, 103 | templatePath: string 104 | ): string | ParseError { 105 | const parsed = parse(template); 106 | if (isParseError(parsed)) { 107 | return parsed; 108 | } 109 | 110 | const heading = `/* 111 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 112 | * 113 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 114 | * Source: ${templatePath} 115 | */ 116 | /* eslint-disable */ 117 | 118 | `; 119 | const file = heading + compile(parsed); 120 | return format(file, { parser: "typescript" }); 121 | } 122 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import { 2 | readdirSync, 3 | existsSync, 4 | readFileSync, 5 | writeFileSync, 6 | statSync, 7 | } from "fs"; 8 | import { basename, join } from "path"; 9 | import { compiler } from "../compiler/index.js"; 10 | import { isParseError } from "../parser/index.js"; 11 | 12 | export type UserConfig = Partial; 13 | 14 | type Config = { source: string }; 15 | 16 | function getConfigFilePath(): string | undefined { 17 | const cwd = process.cwd(); 18 | for (const ext of [".js", ".mjs", ".cjs"]) { 19 | const path = join(cwd, "ets.config") + ext; 20 | if (existsSync(path)) { 21 | return path; 22 | } 23 | } 24 | } 25 | 26 | async function getConfig(): Promise { 27 | const cwd = process.cwd(); 28 | 29 | const defaultConfig = { 30 | source: cwd, 31 | }; 32 | 33 | const configFilePath = getConfigFilePath(); 34 | let userConfig: UserConfig = {}; 35 | if (configFilePath) { 36 | console.info(`Using configuration file at '${configFilePath}'.`); 37 | try { 38 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 39 | userConfig = (await import(configFilePath)).default; 40 | } catch (e) { 41 | console.error(`Failed to load configuration file:`); 42 | console.log(e); 43 | process.exit(1); 44 | } 45 | 46 | const unknownKeys = Object.keys(userConfig).filter( 47 | // eslint-disable-next-line no-prototype-builtins 48 | (key) => !defaultConfig.hasOwnProperty(key) 49 | ); 50 | if (unknownKeys.length) { 51 | console.warn( 52 | `Found unknown configuration options: ${unknownKeys 53 | .map((k) => `'${k}'`) 54 | .join(", ")}.` 55 | ); 56 | } 57 | console.info(); 58 | } 59 | 60 | return { 61 | ...defaultConfig, 62 | ...userConfig, 63 | }; 64 | } 65 | 66 | function findFiles(entry: string, ext: string): string[] { 67 | return readdirSync(entry) 68 | .flatMap((file) => { 69 | const filepath = join(entry, file); 70 | if (statSync(filepath).isDirectory()) { 71 | return findFiles(filepath, ext); 72 | } 73 | return filepath; 74 | }) 75 | .filter((file) => file.endsWith(ext)); 76 | } 77 | 78 | export async function run(): Promise { 79 | const { source } = await getConfig(); 80 | const templates = findFiles(source, ".ets"); 81 | 82 | const created = new Set(); 83 | const updated = new Set(); 84 | const unchanged = new Set(); 85 | templates.forEach((template) => { 86 | const destFile = template + ".ts"; 87 | const templatePath = `./${basename(template)}`; 88 | const out = compiler(readFileSync(template, "utf8"), templatePath); 89 | if (isParseError(out)) { 90 | console.error(`error: ${out.error}`); 91 | console.error( 92 | ` --> ${templatePath}:${out.position.start.line}:${out.position.start.column}` 93 | ); 94 | console.error(out.context); 95 | console.warn(); 96 | return; 97 | } 98 | 99 | function writeFileIfChange(filepath: string, contents: string): void { 100 | if (!existsSync(filepath)) { 101 | writeFileSync(filepath, contents); 102 | created.add(filepath); 103 | } else if (contents !== readFileSync(filepath).toString()) { 104 | writeFileSync(filepath, contents); 105 | updated.add(filepath); 106 | } else { 107 | unchanged.add(filepath); 108 | } 109 | } 110 | 111 | writeFileIfChange(destFile, out); 112 | }); 113 | 114 | if (created.size) { 115 | console.log( 116 | `Created: 117 | ${Array.from(created) 118 | .sort() 119 | .map((name) => ` - ${name}`) 120 | .join("\n")} 121 | ` 122 | ); 123 | } 124 | 125 | if (updated.size) { 126 | console.log( 127 | `Updated: 128 | ${Array.from(updated) 129 | .sort() 130 | .map((name) => ` - ${name}`) 131 | .join("\n")} 132 | ` 133 | ); 134 | } 135 | 136 | if (unchanged.size) { 137 | console.log(`Unchanged: ${unchanged.size}`); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/parser/index.ts: -------------------------------------------------------------------------------- 1 | function isPresent(idx: number): boolean { 2 | return idx !== -1; 3 | } 4 | 5 | export interface Node { 6 | type: "header" | "text" | "expression" | "statement"; 7 | content: string; 8 | } 9 | 10 | interface Range { 11 | line: number; 12 | column: number; 13 | } 14 | 15 | export interface ParseError { 16 | error: string; 17 | position: { 18 | start: Range; 19 | end: Range; 20 | }; 21 | context: string; 22 | } 23 | 24 | const SYMBOLS = { 25 | Header: "---", 26 | Open: "<%", 27 | Close: "%>", 28 | Expression: "=", 29 | }; 30 | 31 | function isExpression(token: string): boolean { 32 | return token.startsWith(SYMBOLS.Expression); 33 | } 34 | 35 | function stripModifierToken(token: string): string { 36 | let stripped = token; 37 | if (isExpression(token)) { 38 | stripped = stripped.slice(1); 39 | } 40 | return stripped; 41 | } 42 | 43 | export function isParseError( 44 | parsed: unknown | ParseError 45 | ): parsed is ParseError { 46 | return typeof parsed === "object" && parsed != null && "error" in parsed; 47 | } 48 | 49 | interface Position { 50 | line: number; 51 | column: number; 52 | } 53 | 54 | function lineAndColumn(template: string, index: number): Position { 55 | const lines = template.slice(0, index).split("\n"); 56 | const line = lines.length; 57 | const column = (lines.pop()?.length ?? 0) + 1; 58 | 59 | return { 60 | line, 61 | column, 62 | }; 63 | } 64 | 65 | function formatContext(template: string, position: Position): string { 66 | const templateLines = template.split("\n").length - 1; 67 | const hasMoreLines = templateLines > position.line; 68 | const line = template.split("\n")[position.line - 1]; 69 | return ` | 70 | ${position.line.toString().padEnd(4, " ")}| ${line} 71 | | ${"^".padStart(position.column, " ")} 72 | | ${"|".padStart(position.column, " ")} 73 | ${hasMoreLines ? "..." : ""} 74 | `; 75 | } 76 | 77 | function parseError({ 78 | error, 79 | template, 80 | startIdx, 81 | endIdx, 82 | }: { 83 | error: string; 84 | template: string; 85 | startIdx: number; 86 | endIdx: number; 87 | }): ParseError { 88 | const start = lineAndColumn(template, startIdx); 89 | const end = lineAndColumn(template, endIdx); 90 | 91 | return { 92 | error, 93 | position: { 94 | start, 95 | end, 96 | }, 97 | context: formatContext(template, start), 98 | }; 99 | } 100 | 101 | export function parse(template: string): Node[] | ParseError { 102 | const parsed: Node[] = []; 103 | let position = 0; 104 | 105 | // header 106 | const headerStartIdx = template.indexOf(SYMBOLS.Header, 0); 107 | if (!isPresent(headerStartIdx)) { 108 | return parseError({ 109 | error: `Expected to find a 'Header' ('${SYMBOLS.Header}') in the template`, 110 | template, 111 | startIdx: 0, 112 | endIdx: template.length - 1, 113 | }); 114 | } 115 | const headerEndIdx = template.indexOf( 116 | SYMBOLS.Header, 117 | headerStartIdx + SYMBOLS.Header.length 118 | ); 119 | if (!isPresent(headerEndIdx)) { 120 | return parseError({ 121 | error: `Expected to find corresponding close to 'Header' ('${SYMBOLS.Header}') before end of template`, 122 | template, 123 | startIdx: headerStartIdx, 124 | endIdx: template.length - 1, 125 | }); 126 | } 127 | const contentBeforeHeader = template.slice(0, headerStartIdx); 128 | const nonWhiteSpaceIdx = contentBeforeHeader.search(/\S/); 129 | if (isPresent(nonWhiteSpaceIdx)) { 130 | return parseError({ 131 | error: `Unexpected token before 'Header' ('${SYMBOLS.Header}')`, 132 | template, 133 | startIdx: nonWhiteSpaceIdx, 134 | endIdx: headerStartIdx, 135 | }); 136 | } 137 | parsed.push({ 138 | type: "header", 139 | content: template.slice( 140 | headerStartIdx + SYMBOLS.Header.length, 141 | headerEndIdx 142 | ), 143 | }); 144 | position = headerEndIdx + SYMBOLS.Header.length; 145 | 146 | // body 147 | while (position < template.length) { 148 | const openIdx = template.indexOf(SYMBOLS.Open, position); 149 | const closeIdx = template.indexOf(SYMBOLS.Close, position); 150 | 151 | if ( 152 | (!isPresent(openIdx) && isPresent(closeIdx)) || 153 | (isPresent(openIdx) && isPresent(closeIdx) && closeIdx < openIdx) 154 | ) { 155 | return parseError({ 156 | error: `Unexpected closing tag '${SYMBOLS.Close}'`, 157 | template, 158 | startIdx: closeIdx, 159 | endIdx: closeIdx + SYMBOLS.Close.length - 1, 160 | }); 161 | } 162 | 163 | if (isPresent(openIdx) && !isPresent(closeIdx)) { 164 | return parseError({ 165 | error: `Expected to find corresponding closing tag '${SYMBOLS.Close}' before end of template`, 166 | template, 167 | startIdx: openIdx, 168 | endIdx: template.length - 1, 169 | }); 170 | } 171 | 172 | const nextOpenIdx = template.indexOf( 173 | SYMBOLS.Open, 174 | openIdx + SYMBOLS.Open.length 175 | ); 176 | if (isPresent(nextOpenIdx) && nextOpenIdx < closeIdx) { 177 | return parseError({ 178 | error: `Unexpected opening tag '${SYMBOLS.Open}'`, 179 | template, 180 | startIdx: nextOpenIdx, 181 | endIdx: nextOpenIdx + SYMBOLS.Open.length - 1, 182 | }); 183 | } 184 | 185 | if (!isPresent(openIdx) && !isPresent(closeIdx)) { 186 | parsed.push({ 187 | type: "text", 188 | content: template.slice(position, template.length), 189 | }); 190 | break; 191 | } 192 | // text before open tag 193 | const text = template.slice(position, openIdx); 194 | if (text.length) { 195 | parsed.push({ type: "text", content: text }); 196 | } 197 | 198 | const code = template.slice(openIdx + SYMBOLS.Open.length, closeIdx).trim(); 199 | if (isExpression(code)) { 200 | parsed.push({ 201 | type: "expression", 202 | content: stripModifierToken(code), 203 | }); 204 | } else { 205 | parsed.push({ type: "statement", content: code }); 206 | } 207 | 208 | position = closeIdx + SYMBOLS.Close.length; 209 | } 210 | 211 | return parsed; 212 | } 213 | -------------------------------------------------------------------------------- /src/compiler/__snapshots__/test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`compiler compile(template-1.ets) 1`] = ` 4 | "/* 5 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 6 | * 7 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 8 | * Source: template-1.ets 9 | */ 10 | /* eslint-disable */ 11 | 12 | export interface Props { 13 | users: { name: string }[]; 14 | } 15 | 16 | export default function (props: Props): string { 17 | let result = ""; 18 | props.users.forEach(function (user) { 19 | result += "Name: "; 20 | result += user.name; 21 | result += "\\n"; 22 | }); 23 | return result; 24 | } 25 | " 26 | `; 27 | 28 | exports[`compiler compile(template-2.ets) 1`] = ` 29 | "/* 30 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 31 | * 32 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 33 | * Source: template-2.ets 34 | */ 35 | /* eslint-disable */ 36 | 37 | export interface Props { 38 | name: string; 39 | needsPasswordReset: boolean; 40 | } 41 | 42 | export default function (props: Props): string { 43 | let result = ""; 44 | result += "Hello "; 45 | result += props.name; 46 | result += "!\\n"; 47 | if (props.needsPasswordReset) { 48 | result += "You need to update your password.\\n"; 49 | } 50 | return result; 51 | } 52 | " 53 | `; 54 | 55 | exports[`compiler compile(template-3.ets) 1`] = ` 56 | "/* 57 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 58 | * 59 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 60 | * Source: template-3.ets 61 | */ 62 | /* eslint-disable */ 63 | 64 | type AccountType = "user" | "admin" | "enterprise"; 65 | 66 | export interface Props { 67 | name: string; 68 | type: AccountType; 69 | } 70 | 71 | export default function (props: Props): string { 72 | let result = ""; 73 | result += "Hello "; 74 | result += props.name; 75 | result += ", you are "; 76 | switch (props.type) { 77 | case "user": { 78 | result += "a user!\\n"; 79 | break; 80 | } 81 | case "admin": { 82 | result += "an admin!\\n"; 83 | break; 84 | } 85 | case "enterprise": { 86 | result += "an enterprise user!\\n"; 87 | break; 88 | } 89 | default: { 90 | const exhaust: never = props.type; 91 | return exhaust; 92 | } 93 | } 94 | return result; 95 | } 96 | " 97 | `; 98 | 99 | exports[`compiler compile(template-4.ets) 1`] = ` 100 | "/* 101 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 102 | * 103 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 104 | * Source: template-4.ets 105 | */ 106 | /* eslint-disable */ 107 | 108 | import { uppercase } from "./helpers"; 109 | 110 | export interface Props { 111 | name: string; 112 | } 113 | 114 | export default function (props: Props): string { 115 | let result = ""; 116 | result += "Hello "; 117 | result += uppercase(props.name); 118 | result += "!"; 119 | return result; 120 | } 121 | " 122 | `; 123 | 124 | exports[`compiler compile(template-5.ets) 1`] = ` 125 | "/* 126 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 127 | * 128 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 129 | * Source: template-5.ets 130 | */ 131 | /* eslint-disable */ 132 | 133 | type AccountType = "user" | "admin" | "enterprise"; 134 | 135 | export interface Props { 136 | name: string; 137 | type: AccountType; 138 | } 139 | 140 | export default function (props: Props): string { 141 | let result = ""; 142 | let userMessage; 143 | switch (props.type) { 144 | case "user": { 145 | userMessage = "a user!"; 146 | break; 147 | } 148 | case "admin": { 149 | userMessage = "an admin!"; 150 | break; 151 | } 152 | case "enterprise": { 153 | userMessage = "an enterprise user!"; 154 | break; 155 | } 156 | default: { 157 | const exhaust: never = props.type; 158 | return exhaust; 159 | } 160 | } 161 | result += "Hello "; 162 | result += props.name; 163 | result += ", you are "; 164 | result += userMessage; 165 | return result; 166 | } 167 | " 168 | `; 169 | 170 | exports[`compiler compile(template-6.ets) 1`] = ` 171 | "/* 172 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 173 | * 174 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 175 | * Source: template-6.ets 176 | */ 177 | /* eslint-disable */ 178 | 179 | import renderUser, { Props as User } from "./user-partial.ets"; 180 | 181 | export interface Props { 182 | users: User[]; 183 | } 184 | 185 | const example = \`1 186 | 2 187 | 3 188 | 4\`; 189 | 190 | export default function (props: Props): string { 191 | let result = ""; 192 | if (props.users.length > 0) { 193 | result += "Here is a list of users:\\n"; 194 | props.users.forEach(function (user) { 195 | result += "\\n "; 196 | result += preserveIndentation(renderUser(user), " "); 197 | result += "\\n"; 198 | }); 199 | result += "\\n"; 200 | } 201 | result += 202 | "The indentation level is preserved for the rendered 'partial'.\\n\\nThere isn't anything special about the 'partial'. Here we used another ets template, but any\\nexpression yeilding a multiline string would be treated the same.\\n\\n "; 203 | result += preserveIndentation(example, " "); 204 | result += "\\n\\nThe end!"; 205 | return result; 206 | } 207 | 208 | function preserveIndentation(text: string, indentation: string): string { 209 | return text 210 | .split("\\n") 211 | .map((line, idx) => (idx === 0 ? line : indentation + line)) 212 | .join("\\n"); 213 | } 214 | " 215 | `; 216 | 217 | exports[`compiler compile(user-partial.ets) 1`] = ` 218 | "/* 219 | * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 220 | * 221 | * Run \`npx ets\` or \`yarn ets\` to regenerate this file. 222 | * Source: user-partial.ets 223 | */ 224 | /* eslint-disable */ 225 | 226 | export interface Props { 227 | name: string; 228 | email: string; 229 | phone: string; 230 | } 231 | 232 | export default function (props: Props): string { 233 | let result = ""; 234 | result += "Name: "; 235 | result += props.name; 236 | result += "\\nEmail: "; 237 | result += props.email; 238 | result += "\\nPhone: "; 239 | result += props.phone; 240 | return result; 241 | } 242 | " 243 | `; 244 | -------------------------------------------------------------------------------- /language-server/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { 3 | createConnection, 4 | TextDocuments, 5 | Diagnostic, 6 | DiagnosticSeverity, 7 | ProposedFeatures, 8 | InitializeParams, 9 | DidChangeConfigurationNotification, 10 | CompletionItem, 11 | // CompletionItemKind, 12 | TextDocumentPositionParams, 13 | TextDocumentSyncKind, 14 | InitializeResult, 15 | } from "vscode-languageserver/node"; 16 | import { TextDocument } from "vscode-languageserver-textdocument"; 17 | import { isParseError, parse, ParseError } from "@ets/parser"; 18 | 19 | // Taken from 20 | // https://github.com/microsoft/vscode-extension-samples/blob/main/lsp-sample/server/src/server.ts 21 | 22 | // Create a connection for the server, using Node's IPC as a transport. 23 | // Also include all preview / proposed LSP features. 24 | const connection = createConnection(ProposedFeatures.all); 25 | 26 | // Create a simple text document manager. 27 | const documents: TextDocuments = new TextDocuments(TextDocument); 28 | 29 | let hasConfigurationCapability = false; 30 | let hasWorkspaceFolderCapability = false; 31 | // let hasDiagnosticRelatedInformationCapability = false; 32 | 33 | connection.onInitialize((params: InitializeParams) => { 34 | const capabilities = params.capabilities; 35 | 36 | // Does the client support the `workspace/configuration` request? 37 | // If not, we fall back using global settings. 38 | hasConfigurationCapability = !!( 39 | capabilities.workspace && !!capabilities.workspace.configuration 40 | ); 41 | hasWorkspaceFolderCapability = !!( 42 | capabilities.workspace && !!capabilities.workspace.workspaceFolders 43 | ); 44 | // hasDiagnosticRelatedInformationCapability = !!capabilities.textDocument 45 | // ?.publishDiagnostics?.relatedInformation; 46 | 47 | const result: InitializeResult = { 48 | capabilities: { 49 | textDocumentSync: TextDocumentSyncKind.Incremental, 50 | // Tell the client that this server supports code completion. 51 | completionProvider: { 52 | resolveProvider: true, 53 | }, 54 | }, 55 | }; 56 | if (hasWorkspaceFolderCapability) { 57 | result.capabilities.workspace = { 58 | workspaceFolders: { 59 | supported: true, 60 | }, 61 | }; 62 | } 63 | return result; 64 | }); 65 | 66 | connection.onInitialized(() => { 67 | if (hasConfigurationCapability) { 68 | // Register for all configuration changes. 69 | connection.client.register( 70 | DidChangeConfigurationNotification.type, 71 | undefined 72 | ); 73 | } 74 | if (hasWorkspaceFolderCapability) { 75 | connection.workspace.onDidChangeWorkspaceFolders((_event) => { 76 | connection.console.log("Workspace folder change event received."); 77 | }); 78 | } 79 | }); 80 | 81 | // The example settings 82 | interface ExampleSettings { 83 | maxNumberOfProblems: number; 84 | } 85 | 86 | // The global settings, used when the `workspace/configuration` request is not supported by the client. 87 | const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 }; 88 | let globalSettings: ExampleSettings = defaultSettings; 89 | 90 | // Cache the settings of all open documents 91 | const documentSettings: Map> = new Map(); 92 | 93 | connection.onDidChangeConfiguration((change) => { 94 | if (hasConfigurationCapability) { 95 | // Reset all cached document settings 96 | documentSettings.clear(); 97 | } else { 98 | globalSettings = ( 99 | (change.settings.languageServerExample || defaultSettings) 100 | ); 101 | } 102 | 103 | // Revalidate all open text documents 104 | documents.all().forEach(validateTextDocument); 105 | }); 106 | 107 | function getDocumentSettings(resource: string): Thenable { 108 | if (!hasConfigurationCapability) { 109 | return Promise.resolve(globalSettings); 110 | } 111 | let result = documentSettings.get(resource); 112 | if (!result) { 113 | result = connection.workspace.getConfiguration({ 114 | scopeUri: resource, 115 | section: "languageServerExample", 116 | }); 117 | documentSettings.set(resource, result); 118 | } 119 | return result; 120 | } 121 | 122 | // Only keep settings for open documents 123 | documents.onDidClose((e) => { 124 | documentSettings.delete(e.document.uri); 125 | }); 126 | 127 | // The content of a text document has changed. This event is emitted 128 | // when the text document first opened or when its content has changed. 129 | documents.onDidChangeContent((change) => { 130 | validateTextDocument(change.document); 131 | }); 132 | 133 | async function validateTextDocument(textDocument: TextDocument): Promise { 134 | // In this simple example we get the settings for every validate run. 135 | const settings = await getDocumentSettings(textDocument.uri); 136 | 137 | const text = textDocument.getText(); 138 | const parsed = parse(text); 139 | const problems: ParseError[] = []; 140 | if (isParseError(parsed)) { 141 | problems.push(parsed); 142 | } 143 | 144 | const diagnostics: Diagnostic[] = []; 145 | problems.forEach((problem) => { 146 | const diagnostic: Diagnostic = { 147 | severity: DiagnosticSeverity.Error, 148 | range: { 149 | start: { 150 | line: problem.position.start.line - 1, 151 | character: problem.position.start.column - 1, 152 | }, 153 | end: { 154 | line: problem.position.end.line - 1, 155 | character: problem.position.end.column - 1, 156 | }, 157 | }, 158 | message: problem.error, 159 | source: "embedded-typescript", 160 | }; 161 | // if (hasDiagnosticRelatedInformationCapability) { 162 | // diagnostic.relatedInformation = [ 163 | // { 164 | // location: { 165 | // uri: textDocument.uri, 166 | // range: Object.assign({}, diagnostic.range), 167 | // }, 168 | // message: "Spelling matters", 169 | // }, 170 | // { 171 | // location: { 172 | // uri: textDocument.uri, 173 | // range: Object.assign({}, diagnostic.range), 174 | // }, 175 | // message: "Particularly for names", 176 | // }, 177 | // ]; 178 | // } 179 | diagnostics.push(diagnostic); 180 | }); 181 | 182 | // Send the computed diagnostics to VSCode. 183 | connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); 184 | } 185 | 186 | connection.onDidChangeWatchedFiles((_change) => { 187 | // Monitored files have change in VSCode 188 | connection.console.log("We received an file change event"); 189 | }); 190 | 191 | // This handler provides the initial list of the completion items. 192 | connection.onCompletion( 193 | (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => { 194 | return []; 195 | // The pass parameter contains the position of the text document in 196 | // which code complete got requested. For the example we ignore this 197 | // info and always provide the same completion items. 198 | // return [ 199 | // { 200 | // label: "TypeScript", 201 | // kind: CompletionItemKind.Text, 202 | // data: 1, 203 | // }, 204 | // { 205 | // label: "JavaScript", 206 | // kind: CompletionItemKind.Text, 207 | // data: 2, 208 | // }, 209 | // ]; 210 | } 211 | ); 212 | 213 | // This handler resolves additional information for the item selected in 214 | // the completion list. 215 | connection.onCompletionResolve((item: CompletionItem): CompletionItem => { 216 | // if (item.data === 1) { 217 | // item.detail = "TypeScript details"; 218 | // item.documentation = "TypeScript documentation"; 219 | // } else if (item.data === 2) { 220 | // item.detail = "JavaScript details"; 221 | // item.documentation = "JavaScript documentation"; 222 | // } 223 | return item; 224 | }); 225 | 226 | // Make the text document manager listen on the connection 227 | // for open, change and close text document events 228 | documents.listen(connection); 229 | connection.listen(); 230 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Embedded TypeScript 2 | 3 |
Type safe TypeScript templates
4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ## What is this? 🧐 24 | 25 | A type safe templating system for TypeScript. Templates are compiled to TypeScript files that you then import for type safe string generation. 26 | 27 | This templating system draws inspiration from ERB, [EJS](https://ejs.co/), [handlebars](https://handlebarsjs.com/) and [mustache](https://github.com/janl/mustache.js). This project embraces the "just JavaScript" spirit of `ejs` and adds some of the helpful white space semantics of `mustache`. 28 | 29 | Checkout the [examples](#examples-) or [play with embedded-typescript in your browser](https://codesandbox.io/s/ets-playground-9mzk8). 30 | 31 | ## Installation & Usage 📦 32 | 33 | 1. Add this package to your project: 34 | 35 | `npm install embedded-typescript` or `yarn add embedded-typescript` 36 | 37 | ## Motivation 38 | 39 | `Hello undefined!` 40 | 41 | When using a typed language, I want my templates to be type checked. For most cases, [template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) work well. If I'm writing HTML/XML, [JSX](https://www.typescriptlang.org/docs/handbook/jsx.html) works well. When I'm writing text templates, template literals quickly become difficult to maintain as the template complexity grows. I can switch to [EJS](https://ejs.co/), [handlebars](https://handlebarsjs.com/), [mustache](https://github.com/janl/mustache.js), etc, but then I lose the type safety I had with template literals. Sometimes I want the expressiveness of a templating language without losing type safety. For those cases, I wrote `embedded-typescript`. 42 | 43 | ## Syntax 44 | 45 | | Syntax | Name | Description | 46 | | ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | 47 | | `--- CODE ---` | Header | Defines code that should live outside of the generated render function. Use this to define `Props` and any `import`s, `export`s or constants. | 48 | | `<%= EXPRESSION %>` | Expression | Inserts the value of an expression. If the expression generates multiple lines, the indentation level is preserved across all resulting lines. | 49 | | `<% CODE %>` | Statement | Executes code, but does not insert a value. | 50 | | `TEXT` | Text | Text literals are inserted as is. All white space is preserved. | 51 | 52 | ## Examples 🚀 53 | 54 | #### Minimal 55 | 56 | 1. Write a template file: `my-template.ets`: 57 | 58 | ```typescript 59 | --- 60 | interface Props { 61 | users: { 62 | name: string; 63 | }[] 64 | } 65 | --- 66 | <% props.users.forEach(function(user) { %> 67 | Name: <%= user.name %> 68 | <% }) %> 69 | ``` 70 | 71 | 2. Run the compiler: `npx ets`. This will compile any files with the `.ets` extension. `my-template.ets.ts` will be generated. 72 | 73 | 3. Import the generated `.ets.ts` file wherever you'd like to render your template: 74 | 75 | ```typescript 76 | import render from "./my-template.ets"; 77 | 78 | /* will output: 79 | Name: Alice 80 | Name: Bob 81 | */ 82 | 83 | console.log(render({ users: [{ name: "Alice" }, { name: "Bob" }] })); 84 | ``` 85 | 86 | Note that the arguments to your template function are type checked. You define the arguments to your template function by defining a `type` or `interface` named `Props`. 87 | 88 | #### Partials 89 | 90 | Embedded TypeScript preserves the indentation wherever an `expression` tag (`<%= EXPRESSION %>`) is used. This means there isn't any special syntax for partials, and `ets` templates nest as you would expect. 91 | 92 | 1. Write a "partial" `user-partial.ets`: 93 | 94 | ```typescript 95 | --- 96 | interface Props { 97 | name: string; 98 | email: string; 99 | phone: string; 100 | } 101 | --- 102 | Name: <%= props.user.name %> 103 | Email: <%= props.user.email %> 104 | Phone: <%= props.user.phone %> 105 | ``` 106 | 107 | Note there is nothing special about `user-partial.ets`, it's just an `ets` template. We're using the `-partial` suffix purely for illustration. 108 | 109 | 2. Import your "partial" into another `ets` template `my-template-2.ets`: 110 | 111 | ```typescript 112 | --- 113 | import renderUser, { Props as User } from './user-partial.ets'; 114 | 115 | interface Props { 116 | users: User[]; 117 | } 118 | 119 | const example = 120 | `1 121 | 2 122 | 3 123 | 4`; 124 | --- 125 | <% if (props.users.length > 0) { %> 126 | Here is a list of users: 127 | 128 | <% props.users.forEach(function(user) { %> 129 | <%= renderUser(user) %> 130 | <% }) %> 131 | 132 | <% } %> 133 | The indentation level is preserved for the rendered 'partial'. 134 | 135 | There isn't anything special about the 'partial'. Here we used another `.ets` template, but any 136 | expression yeilding a multiline string would be treated the same. 137 | 138 | <%= example %> 139 | 140 | The end! 141 | ``` 142 | 143 | 3. Run the compiler: `npx ets`. 144 | 145 | 4. Import the generated `my-template-2.ets.ts` file wherever you'd like to render your template: 146 | 147 | ```typescript 148 | import render from "./my-template-2.ets"; 149 | 150 | /* will output: 151 | Here is a list of users: 152 | 153 | Name: Tate 154 | Email: tate@tate.com 155 | Phone: 888-888-8888 156 | 157 | Name: Emily 158 | Email: emily@emily.com 159 | Phone: 777-777-7777 160 | 161 | The indentation level is preserved for the rendered 'partial'. 162 | 163 | There isn't anything special about the 'partial'. Here we used another `ets` template, but any 164 | expression yielding a multi-line string would be treated the same. 165 | 166 | 1 167 | 2 168 | 3 169 | 4 170 | 171 | The end! 172 | */ 173 | 174 | console.log( 175 | render({ 176 | users: [ 177 | { name: "Tate", phone: "888-888-8888", email: "tate@tate.com" }, 178 | { name: "Emily", phone: "777-777-7777", email: "emily@emily.com" }, 179 | ], 180 | }) 181 | ); 182 | ``` 183 | 184 | Note that indentation was preserved for all lines rendered by `user-partial.ets` and all lines of the `example` variable. Any expression yielding a multi-line string rendered inside an `expresssion` block (`<%= EXPRESSION %>`) will apply the indentation across each line. 185 | 186 | #### More Examples 187 | 188 | For more examples, take a look at the [e2e directory](https://github.com/tatethurston/embedded-typescript/blob/main/e2e). The `*.ets.ts` files are generated by the compiler from the `*.ets` template files. The corresponding `*${NAME}.test.ts` shows example usage and output. 189 | 190 | ## Understanding Error Messages 191 | 192 | The compiler will output errors when it encounters invalid syntax: 193 | 194 | ``` 195 | error: Unexpected closing tag '%>' 196 | --> ./template-1.ets:4:41 197 | | 198 | 4 | <% users.forEach(function(user) { %>%> 199 | | ^ 200 | | | 201 | ... 202 | ``` 203 | 204 | The first line is a description of the error that was encountered. 205 | 206 | The second line is location of the error, in `path:line:column` notation. 207 | 208 | The next 5 lines provide visual context for the error. 209 | 210 | ## Notable deviations from prior art 211 | 212 | This tool specifically targets text templating, rather than HTML templating. Think: code generation, text message content (emails or SMS), etc. HTML templating is possible with this tool, but I would generally recommend JSX instead of `embedded-typescript` for HTML. 213 | 214 | The templating system does _not_ perform any HTML escaping. You can `import` any self authored or 3rd party HTML escaping utilities in your template, and call that directly on any untrusted input: 215 | 216 | ```typescript 217 | --- 218 | import htmlescape from 'htmlescape'; 219 | 220 | interface Props { 221 | users: { name: string}[]; 222 | } 223 | --- 224 | <% props.users.forEach(function(user) { %> 225 |

Name: <%= htmlescape(user.name) %>

226 | <% }) %> 227 | ``` 228 | 229 | I'm not aware of any other templating systems that preserve indentation for partials and multi-line strings like Embedded TypeScript. Many templating libraries target HTML so this is not surprising, but I've found this functionality useful for text templates. 230 | 231 | ## Highlights 232 | 233 | 🎁 Zero run time dependencies 234 | 235 | ## Configuration 🛠 236 | 237 | Embedded TypeScript aims to be zero config, but can be configured by creating an `ets.config.mjs` (or `.js` or `.cjs`) file in your project root. 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 269 | 270 | 271 | 272 |
NameDescriptionType
source 251 | The root directory. `.ets` files will be searched under this directory. Embedded TypeScript will recursively search all subdirectories for `.ets` files. 252 | 253 | Defaults to the project root. 254 | 255 | Example: 256 | 257 | Search for `.ets` files under a directory named `src` 258 | 259 | // ets.config.mjs 260 | 261 | ```js 262 | /** @type {import('ets').Config} */ 263 | export default { 264 | source: "src", 265 | }; 266 | ``` 267 | 268 | string (filepath)
273 | 274 | ## Contributing 👫 275 | 276 | PR's and issues welcomed! For more guidance check out [CONTRIBUTING.md](https://github.com/tatethurston/embedded-typescript/blob/master/CONTRIBUTING.md) 277 | 278 | ## Licensing 📃 279 | 280 | See the project's [MIT License](https://github.com/tatethurston/embedded-typescript/blob/master/LICENSE). 281 | --------------------------------------------------------------------------------