├── .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 | | Name |
243 | Description |
244 | Type |
245 |
246 |
247 |
248 |
249 | | source |
250 |
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 | |
269 | string (filepath) |
270 |
271 |
272 |
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 |
--------------------------------------------------------------------------------