├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .node-version ├── .prettierignore ├── .prettierrc.yml ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── rollup.config.cjs.js ├── src ├── __snapshots__ │ └── index.test.ts.snap ├── cache.ts ├── clone-node.test.ts ├── clone-node.ts ├── example │ └── index.ts ├── index.test.ts └── index.ts ├── tsconfig.build.json └── tsconfig.json /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version-file: ".node-version" 19 | - name: npm install, build, and test 20 | run: | 21 | npm ci 22 | npm run build 23 | npm run lint 24 | npm run test:ci 25 | env: 26 | CI: true 27 | # - uses: codecov/codecov-action@v1 28 | # with: 29 | # name: jest 30 | # token: ${{ secrets.CODECOV_TOKEN }} 31 | # file: ./coverage/coverage-final.json 32 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version-file: ".node-version" 18 | - name: npm publish 19 | run: | 20 | echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" > ~/.npmrc 21 | npm whoami 22 | npm ci 23 | npm run build 24 | npm publish 25 | if: contains(github.ref, 'tags/v') 26 | env: 27 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 28 | CI: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .rollup.cache/ 107 | 108 | lib/ 109 | lib_cjs/ 110 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.13.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | coverage/ 3 | *.tsbuildinfo 4 | *.png 5 | .gitignore 6 | .npmignore 7 | node_modules/ 8 | *.sh 9 | *.log 10 | .env 11 | .env.* 12 | __snapshots__/ 13 | .husky/ 14 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: all 2 | tabWidth: 2 3 | semi: true 4 | singleQuote: false 5 | bracketSpacing: true 6 | printWidth: 120 7 | arrowParens: avoid 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yosuke Kurami 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Talt 2 | 3 | [![github actions](https://github.com/Quramy/talt/workflows/build/badge.svg)](https://github.com/Quramy/talt/actions) 4 | [![npm version](https://badge.fury.io/js/talt.svg)](https://badge.fury.io/js/talt) 5 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Quramy/talt/main/LICENSE) 6 | 7 | Template functions to generate TypeScript AST, inspired from [@babel/template](https://babeljs.io/docs/en/babel-template) . 8 | 9 | ## Install 10 | 11 | ```sh 12 | $ npm i talt typescript 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```ts 18 | import ts from "typescript"; 19 | import { template } from "talt"; 20 | 21 | const typeNode = template.typeNode("{ readonly hoge: string }")(); 22 | 23 | // You can use `template` as tag function. 24 | const typeNodeUsingTagFn = template.typeNode` 25 | { 26 | readonly hoge: string; 27 | } 28 | `(); 29 | 30 | // The following returns ts.BinaryExpression 31 | const binaryExpression = template.expression("60 * 1000")(); 32 | 33 | // You can use identifier placeholder. 34 | const compiledFn = template.expression` 35 | 60 * SOME_PLACEHOLDER_KEY 36 | `; 37 | const generatedAst = compiledFn({ 38 | SOME_PLACEHOLDER_KEY: binaryExpression, 39 | }); // returns expression node, `60 * 60 * 1000` 40 | 41 | const generetedOtherNode = compiledFn({ 42 | SOME_PLACEHOLDER_KEY: ts.factory.createNumericLiteral("200"), 43 | }); // returns expression node, `60 * 200` 44 | 45 | // You can use any function which returns ts.Node instead of identifier placeholder. 46 | const altCompiledFn = template.expression` 47 | 60 * ${() => binaryExpression} 48 | `; // returns expression node, `60 * 60 * 1000` 49 | ``` 50 | 51 | ## API 52 | 53 | `template` has the following tag functions. Each tag function compiles and provides corresponding type AST. 54 | 55 | - `template.typeNode` 56 | - `template.expression` 57 | - `template.statement` 58 | - `template.sourceFile` 59 | - `template.jsxAttribute` 60 | 61 | ## License 62 | 63 | MIT 64 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extensionsToTreatAsEsm: [".ts", ".mts"], 3 | transform: { 4 | "^.+\\.(mc)?ts$": [ 5 | "ts-jest", 6 | { 7 | diagnostics: false, 8 | useESM: true, 9 | }, 10 | ], 11 | }, 12 | moduleNameMapper: { 13 | "^(\\.\\.?/.*)\\.js$": ["$1.ts", "$1.js"], 14 | "^(\\.\\.?/.*)\\.mjs$": ["$1.mts", "$1.mjs"], 15 | "^(\\.\\.?/.*)\\.cjs$": ["$1.cts", "$1.cjs"], 16 | }, 17 | testMatch: ["**/?(*.)+(spec|test).?([mc])[jt]s"], 18 | testPathIgnorePatterns: ["/node_modules/", "/.rollup.cache/", "\\.d\\.ts$", "lib/.*", "lib_cjs/.*"], 19 | collectCoverageFrom: ["src/**/*.?([mc])ts", "!src/**/*.test.*"], 20 | moduleFileExtensions: ["ts", "mts", "cts", "js", "mjs", "cjs", "json"], 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "talt", 3 | "version": "2.4.4", 4 | "description": "Template functions to generate TypeScript AST.", 5 | "type": "module", 6 | "types": "./lib/index.d.ts", 7 | "main": "./lib_cjs/index.js", 8 | "exports": { 9 | ".": { 10 | "import": { 11 | "types": "./lib/index.d.ts", 12 | "default": "./lib/index.js" 13 | }, 14 | "require": { 15 | "types": "./lib/index.d.ts", 16 | "default": "./lib_cjs/index.cjs" 17 | } 18 | } 19 | }, 20 | "files": [ 21 | "lib", 22 | "lib_cjs" 23 | ], 24 | "scripts": { 25 | "prepare": "husky", 26 | "clean": "rimraf lib lib_cjs \"*.tsbuildinfo\" .rollup.cache coverage", 27 | "build": "npm run build:esm && npm run build:cjs", 28 | "build:esm": "tsc -p tsconfig.build.json", 29 | "build:cjs": "rollup -c rollup.config.cjs.js", 30 | "test": "NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest", 31 | "test:ci": "NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --coverage", 32 | "prettier": "prettier .", 33 | "format": "npm run prettier -- --write", 34 | "format:check": "npm run prettier -- --check", 35 | "lint": "npm run format:check" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/Quramy/talt.git" 40 | }, 41 | "keywords": [ 42 | "TypeScript", 43 | "AST" 44 | ], 45 | "author": "Quramy", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/quramy/talt/issues" 49 | }, 50 | "homepage": "https://github.com/quramy/talt#readme", 51 | "peerDependencies": { 52 | "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" 53 | }, 54 | "devDependencies": { 55 | "@rollup/plugin-typescript": "12.1.1", 56 | "@types/jest": "29.5.13", 57 | "husky": "9.0.11", 58 | "jest": "29.7.0", 59 | "prettier": "3.3.3", 60 | "pretty-quick": "4.0.0", 61 | "rimraf": "6.0.1", 62 | "rollup": "4.34.0", 63 | "ts-jest": "29.2.5", 64 | "typescript": "5.5.3" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"] 3 | } 4 | -------------------------------------------------------------------------------- /rollup.config.cjs.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | 3 | export default { 4 | input: "src/index.ts", 5 | output: { 6 | file: "lib_cjs/index.cjs", 7 | format: "cjs", 8 | }, 9 | external: ["typescript"], 10 | plugins: [typescript()], 11 | }; 12 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Node type expressionTag 1`] = `"{ a: 1 }"`; 4 | 5 | exports[`Node type jsxAttributeTag 1`] = `"id={id}"`; 6 | 7 | exports[`Node type sourceTag 1`] = ` 8 | "type a = 100; 9 | " 10 | `; 11 | 12 | exports[`Node type statementTag 1`] = `"type a = 100;"`; 13 | 14 | exports[`Node type typeNodeTag 1`] = ` 15 | "{ 16 | a: 1; 17 | }" 18 | `; 19 | 20 | exports[`Replacement id placeholder anonymous function 1`] = `"100 * fuga * 10"`; 21 | 22 | exports[`Replacement id placeholder identifier to identifier replacement at nested type reference 1`] = `"type X = Y;"`; 23 | 24 | exports[`Replacement id placeholder identifier to identifier replacement at type reference 1`] = `"type X = After;"`; 25 | 26 | exports[`Replacement id placeholder identifier to type node replacement at nested type reference 1`] = `"type X = Y<{}>;"`; 27 | 28 | exports[`Replacement id placeholder identifier to type node replacement at type reference 1`] = `"type X = {};"`; 29 | 30 | exports[`Replacement id placeholder nested 1`] = `"100 * hoge * 200"`; 31 | 32 | exports[`Replacement id placeholder replacement 1`] = `"100 + 200 * 300"`; 33 | 34 | exports[`Replacement id placeholder same identifiers 1`] = `"100 + 200 * 300 + 200 * 300"`; 35 | 36 | exports[`Replacement node bind 1`] = ` 37 | "{ 38 | a: A; 39 | b: B; 40 | }" 41 | `; 42 | 43 | exports[`Replacement string placeholder 1`] = ` 44 | "{ 45 | a: A; 46 | b: B; 47 | }" 48 | `; 49 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | export class LRUCache { 2 | private _cacheMap = new Map(); 3 | 4 | constructor(private _maxSize: number = 100) {} 5 | 6 | set(key: K, value: V) { 7 | this._cacheMap.set(key, value); 8 | if (this._cacheMap.size > this._maxSize) { 9 | const lru = this._cacheMap.keys().next(); 10 | this._cacheMap.delete(lru.value); 11 | } 12 | } 13 | 14 | get(key: K) { 15 | const result = this._cacheMap.get(key); 16 | if (!result) return; 17 | return result; 18 | } 19 | 20 | has(key: K) { 21 | return this._cacheMap.has(key); 22 | } 23 | 24 | touch(key: K) { 25 | const result = this._cacheMap.get(key); 26 | if (!result) return; 27 | this._cacheMap.delete(key); 28 | this._cacheMap.set(key, result); 29 | } 30 | 31 | del(key: K) { 32 | this._cacheMap.delete(key); 33 | } 34 | 35 | clearAll() { 36 | this._cacheMap = new Map(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/clone-node.test.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | import { cloneNode } from "./clone-node.js"; 4 | 5 | describe(cloneNode, () => { 6 | it("should create a synthesized node", () => { 7 | const orig: ts.Node = ts.factory.createIdentifier("hoge"); 8 | (orig as any).flags = 0; 9 | const cloned = cloneNode(orig); 10 | expect(cloned.flags & ts.NodeFlags.Synthesized).toBe(ts.NodeFlags.Synthesized); 11 | }); 12 | 13 | it("should not link to original node", () => { 14 | const orig: ts.Node = ts.factory.createIdentifier("hoge"); 15 | const cloned = cloneNode(orig); 16 | expect(ts.getOriginalNode(cloned)).toBe(cloned); 17 | }); 18 | 19 | it("should copy from node", () => { 20 | const orig: ts.Node = ts.factory.createIdentifier("hoge"); 21 | const cloned = cloneNode(orig); 22 | expect(cloned.getChildren()).toStrictEqual([]); 23 | expect(cloned).not.toBe(orig); 24 | }); 25 | 26 | it("should shallow copy children", () => { 27 | const orig: ts.Node = ts.factory.createExpressionStatement( 28 | ts.factory.createBinaryExpression( 29 | ts.factory.createIdentifier("hoge"), 30 | ts.factory.createToken(ts.SyntaxKind.PlusToken), 31 | ts.factory.createIdentifier("fuga"), 32 | ), 33 | ); 34 | const cloned = cloneNode(orig); 35 | expect(cloned).not.toBe(orig); 36 | const childrenFromOrig: ts.Node[] = []; 37 | orig.forEachChild(c => childrenFromOrig.push(c)); 38 | const childrenFromCloned: ts.Node[] = []; 39 | cloned.forEachChild(c => childrenFromCloned.push(c)); 40 | childrenFromCloned.forEach((c, i) => expect(c).toBe(childrenFromOrig[i])); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/clone-node.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | // Typescript undocumented API, see Ron Buckton's explaination why it's not public: 4 | // https://github.com/microsoft/TypeScript/issues/40507#issuecomment-737628756 5 | // But it's very fit our case 6 | const { cloneNode: _cloneNode } = ts.factory as any; 7 | 8 | export function cloneNode(node: T): T { 9 | const cloned = _cloneNode(node); 10 | return ts.setOriginalNode(cloned, undefined); // remove original 11 | } 12 | -------------------------------------------------------------------------------- /src/example/index.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | import { template } from "../index.js"; 4 | 5 | const typeNode = template.typeNode("{ readonly hoge: string }")(); 6 | 7 | // You can use `template` as tag function. 8 | const typeNodeUsingTagFn = template.typeNode` 9 | { 10 | readonly hoge: string; 11 | } 12 | `(); 13 | 14 | // The following returns ts.BinaryExpression 15 | const binaryExpression = template.expression("60 * 1000")(); 16 | 17 | // You can use identifier placeholder. 18 | const compiledFn = template.expression` 19 | 60 * SOME_PLACEHOLDER_KEY 20 | `; 21 | const generatedAst = compiledFn({ 22 | SOME_PLACEHOLDER_KEY: binaryExpression, 23 | }); // returns expression node, `60 * 60 * 1000` 24 | 25 | const generetedOtherNode = compiledFn({ 26 | SOME_PLACEHOLDER_KEY: ts.factory.createNumericLiteral("200"), 27 | }); // returns expression node, `60 * 200` 28 | 29 | // You can use any function which returns ts.Node instead of identifier placeholder. 30 | const altCompiledFn = template.expression` 31 | 60 * ${() => binaryExpression} 32 | `; // returns expression node, `60 * 60 * 1000` 33 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | import { template, printNode, clearCache } from "./index.js"; 4 | 5 | describe("Node type", () => { 6 | test(template.typeNode.name, () => { 7 | const node = template.typeNode`{ a: 1 }`(); 8 | expect(ts.isTypeLiteralNode(node)).toBeTruthy(); 9 | expect(printNode(node)).toMatchSnapshot(); 10 | }); 11 | 12 | test(template.expression.name, () => { 13 | const node = template.expression`{ a: 1 }`(); 14 | expect(ts.isObjectLiteralExpression(node)).toBeTruthy(); 15 | expect(printNode(node)).toMatchSnapshot(); 16 | }); 17 | 18 | test(template.statement.name, () => { 19 | const node = template.statement`type a = 100`(); 20 | expect(ts.isTypeAliasDeclaration(node)).toBeTruthy(); 21 | expect(printNode(node)).toMatchSnapshot(); 22 | }); 23 | 24 | test(template.jsxAttribute.name, () => { 25 | const node = template.jsxAttribute`id={id}`(); 26 | expect(ts.isJsxAttribute(node)).toBeTruthy(); 27 | expect(printNode(node)).toMatchSnapshot(); 28 | }); 29 | 30 | test(template.sourceFile.name, () => { 31 | const node = template.sourceFile`type a = 100`(); 32 | expect(ts.isSourceFile(node)).toBeTruthy(); 33 | expect(printNode(node)).toMatchSnapshot(); 34 | }); 35 | }); 36 | 37 | describe("Replacement", () => { 38 | test("compiled function generates new node instance", () => { 39 | const fn = template.expression("100 + 100"); 40 | const nodeA = fn(); 41 | const nodeB = fn(); 42 | expect(nodeA === nodeB).toBeFalsy(); 43 | }); 44 | 45 | test("Generated node does not have position", () => { 46 | const fn = template.expression("hoge"); 47 | const node = fn(); 48 | expect(() => node.getStart()).toThrowError(); 49 | expect(() => node.getWidth()).toThrowError(); 50 | }); 51 | 52 | test("string placeholder", () => { 53 | const idA = "A"; 54 | const idB = "B"; 55 | const node = template.typeNode`{ a: ${idA}, b: ${idB} }`(); 56 | expect(printNode(node)).toMatchSnapshot(); 57 | }); 58 | 59 | test("node bind", () => { 60 | const idA = ts.factory.createIdentifier("A"); 61 | const idB = ts.factory.createIdentifier("B"); 62 | const node = template.typeNode`{ a: ${idA}, b: ${idB} }`(); 63 | expect(printNode(node)).toMatchSnapshot(); 64 | }); 65 | 66 | describe("id placeholder", () => { 67 | test("replacement", () => { 68 | const exp = template.expression`200 * 300`(); 69 | const fn = template.expression`100 + TO_BE_REPLACED`; 70 | const node = fn({ 71 | TO_BE_REPLACED: exp, 72 | }); 73 | expect(printNode(node)).toMatchSnapshot(); 74 | }); 75 | 76 | test("same identifiers", () => { 77 | const exp = template.expression`200 * 300`(); 78 | const fn = template.expression`100 + TO_BE_REPLACED + TO_BE_REPLACED`; 79 | const node = fn({ 80 | TO_BE_REPLACED: exp, 81 | }); 82 | expect(printNode(node)).toMatchSnapshot(); 83 | }); 84 | 85 | test("anonymous function", () => { 86 | const node = template.expression` 87 | 100 * ${template.expression`fuga * ${() => ts.factory.createNumericLiteral(10)}`} 88 | `(); 89 | expect(printNode(node)).toMatchSnapshot(); 90 | }); 91 | 92 | test("nested", () => { 93 | const node = template.expression` 94 | 100 * ${template.expression`hoge * TO_BE_REPLACED`} 95 | `({ 96 | TO_BE_REPLACED: template.expression`200`(), 97 | }); 98 | expect(printNode(node)).toMatchSnapshot(); 99 | }); 100 | 101 | test("identifier to type node replacement at type reference", () => { 102 | const node = template.statement` 103 | type X = TO_BE_REPLACED 104 | `({ 105 | TO_BE_REPLACED: ts.factory.createTypeLiteralNode([]), 106 | }); 107 | expect(printNode(node)).toMatchSnapshot(); 108 | }); 109 | 110 | test("identifier to identifier replacement at type reference", () => { 111 | const node = template.statement` 112 | type X = TO_BE_REPLACED 113 | `({ 114 | TO_BE_REPLACED: ts.factory.createIdentifier("After"), 115 | }); 116 | expect(printNode(node)).toMatchSnapshot(); 117 | }); 118 | 119 | test("identifier to type node replacement at nested type reference", () => { 120 | const node = template.statement` 121 | type X = Y 122 | `({ 123 | TO_BE_REPLACED: ts.factory.createTypeLiteralNode([]), 124 | }); 125 | expect(printNode(node)).toMatchSnapshot(); 126 | }); 127 | 128 | test("identifier to identifier replacement at nested type reference", () => { 129 | const node = template.statement` 130 | type X = Y 131 | `({ 132 | TO_BE_REPLACED: ts.factory.createIdentifier("After"), 133 | }); 134 | expect(printNode(node)).toMatchSnapshot(); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { LRUCache } from "./cache.js"; 3 | import { cloneNode } from "./clone-node.js"; 4 | 5 | export interface TypeScriptASTGenerator { 6 | (idPlaceholders?: Record): T; 7 | } 8 | 9 | export type Placeholder = string | ts.Node | TypeScriptASTGenerator; 10 | 11 | export interface TypeScriptASTGeneratorBuilder { 12 | ( 13 | templateStrings: string | TemplateStringsArray, 14 | ...placeholders: Placeholder[] 15 | ): TypeScriptASTGenerator; 16 | } 17 | 18 | type TypeScriptASTGeneratorMap = Map>; 19 | 20 | const HIDDEN_IDENTIFIER_NAME = "__TALT_HIDDEN__"; 21 | 22 | const dummySrc = createSourceFile(""); 23 | 24 | const printer = ts.createPrinter({ removeComments: true }); 25 | 26 | const sourceFileCache = new LRUCache(200); 27 | 28 | function replace(s: T, idPlaceholders: Record | undefined): T { 29 | const factory: ts.TransformerFactory = ctx => { 30 | const visitor = (node: ts.Node): ts.Node => { 31 | if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { 32 | const idv = node.typeName.escapedText as string; 33 | if (!idv || !idPlaceholders || !idPlaceholders![idv]) return cloneNode(ts.visitEachChild(node, visitor, ctx)); 34 | const after = idPlaceholders![idv]; 35 | if (ts.isIdentifier(after) || ts.isQualifiedName(after)) { 36 | const typeArguments = node.typeArguments 37 | ? (ts.visitNodes(node.typeArguments, n => 38 | cloneNode(ts.visitEachChild(n, visitor, ctx)), 39 | ) as ts.NodeArray) 40 | : undefined; 41 | return ts.factory.updateTypeReferenceNode(node, after, typeArguments); 42 | } else { 43 | return after; 44 | } 45 | } else if (ts.isIdentifier(node)) { 46 | const idv = node.escapedText as string; 47 | if (!idv || !idPlaceholders || !idPlaceholders![idv]) return cloneNode(node); 48 | return idPlaceholders![idv]; 49 | } else { 50 | return cloneNode(ts.visitEachChild(node, visitor, ctx)); 51 | } 52 | }; 53 | return visitor; 54 | }; 55 | const result = ts.transform(s, [factory]); 56 | const node = result.transformed[0] as T; 57 | result.dispose(); 58 | return node; 59 | } 60 | 61 | function createAstGenerator( 62 | templateNode: T, 63 | astGeneratorMap: TypeScriptASTGeneratorMap, 64 | ): TypeScriptASTGenerator { 65 | return placeholders => { 66 | const idPlaceholders = { ...placeholders }; 67 | for (const [key, generatorFn] of astGeneratorMap.entries()) { 68 | idPlaceholders[key] = generatorFn(placeholders); 69 | } 70 | return replace(templateNode, idPlaceholders); 71 | }; 72 | } 73 | 74 | function sourceTextFrom( 75 | templateStrings: TemplateStringsArray | string, 76 | ...placeholders: Placeholder[] 77 | ): [string, TypeScriptASTGeneratorMap] { 78 | const fnMap = new Map>(); 79 | if (typeof templateStrings === "string") return [templateStrings, fnMap]; 80 | let sourceText = templateStrings[0]; 81 | for (let i = 1; i < templateStrings.length; i++) { 82 | const p = placeholders[i - 1]; 83 | if (typeof p === "function") { 84 | const key = `_ID_FN${i}_`; 85 | sourceText += key; 86 | fnMap.set(key, p); 87 | } else if (typeof p === "string") { 88 | sourceText += p; 89 | } else { 90 | sourceText += printNode(p); 91 | } 92 | sourceText += templateStrings[i]; 93 | } 94 | return [sourceText, fnMap]; 95 | } 96 | 97 | function createSourceFile(srcString: string) { 98 | return ts.createSourceFile("", srcString, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX); 99 | } 100 | 101 | function parseSourceFile(sourceText: string) { 102 | const key = sourceText; 103 | let sourceFile = sourceFileCache.get(key); 104 | if (!sourceFile) { 105 | sourceFile = createSourceFile(sourceText); 106 | sourceFileCache.set(key, sourceFile); 107 | } 108 | return sourceFile; 109 | } 110 | 111 | function parseStatement(statementText: string) { 112 | const sourceFile = parseSourceFile(statementText); 113 | return sourceFile.statements[0] as T; 114 | } 115 | 116 | function sourceTag( 117 | templateStrings: string | TemplateStringsArray, 118 | ...placeholders: Placeholder[] 119 | ) { 120 | const [text, lazyAstGeneratorMap] = sourceTextFrom(templateStrings, ...placeholders); 121 | const sourceFile = parseSourceFile(text); 122 | return createAstGenerator(sourceFile as T, lazyAstGeneratorMap); 123 | } 124 | 125 | function statementTag( 126 | templateStrings: string | TemplateStringsArray, 127 | ...placeholders: Placeholder[] 128 | ) { 129 | const [text, lazyAstGeneratorMap] = sourceTextFrom(templateStrings, ...placeholders); 130 | const statement = parseStatement(text); 131 | return createAstGenerator(statement, lazyAstGeneratorMap); 132 | } 133 | 134 | function typeNodeTag( 135 | templateStrings: string | TemplateStringsArray, 136 | ...placeholders: Placeholder[] 137 | ) { 138 | const [text, lazyAstGeneratorMap] = sourceTextFrom(templateStrings, ...placeholders); 139 | const statement = parseStatement(`type ${HIDDEN_IDENTIFIER_NAME} = ${text}`); 140 | return createAstGenerator(statement.type as T, lazyAstGeneratorMap); 141 | } 142 | 143 | function expressionTag( 144 | templateStrings: string | TemplateStringsArray, 145 | ...placeholders: Placeholder[] 146 | ) { 147 | const [text, lazyAstGeneratorMap] = sourceTextFrom(templateStrings, ...placeholders); 148 | const statement = parseStatement(`${HIDDEN_IDENTIFIER_NAME} = ${text}`); 149 | const expression = statement.expression as ts.BinaryExpression; 150 | return createAstGenerator(expression.right as T, lazyAstGeneratorMap); 151 | } 152 | 153 | function jsxAttributeTag( 154 | templateStrings: string | TemplateStringsArray, 155 | ...placeholders: Placeholder[] 156 | ) { 157 | const [text, lazyAstGeneratorMap] = sourceTextFrom(templateStrings, ...placeholders); 158 | const statement = parseStatement(`
`); 159 | const element = statement.expression as ts.JsxSelfClosingElement; 160 | return createAstGenerator(element.attributes.properties[0] as T, lazyAstGeneratorMap); 161 | } 162 | 163 | export function clearCache() { 164 | sourceFileCache.clearAll(); 165 | } 166 | 167 | export function printNode(node: ts.Node) { 168 | return printer.printNode(ts.EmitHint.Unspecified, node, dummySrc); 169 | } 170 | 171 | export const template: { 172 | readonly sourceFile: TypeScriptASTGeneratorBuilder; 173 | readonly statement: TypeScriptASTGeneratorBuilder; 174 | readonly typeNode: TypeScriptASTGeneratorBuilder; 175 | readonly expression: TypeScriptASTGeneratorBuilder; 176 | readonly jsxAttribute: TypeScriptASTGeneratorBuilder; 177 | } = { 178 | sourceFile: sourceTag, 179 | statement: statementTag, 180 | typeNode: typeNodeTag, 181 | expression: expressionTag, 182 | jsxAttribute: jsxAttributeTag, 183 | }; 184 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true, 6 | "module": "node16", 7 | "rootDir": "./src", 8 | "outDir": "./lib" 9 | }, 10 | "exclude": ["lib", "**/*.test.ts", "**/*.test.cts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | "incremental": true /* Enable incremental compilation */, 7 | "target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 8 | "module": "node16" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true /* Generates corresponding '.map' file. */, 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | "noEmit": true /* Do not emit outputs. */, 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 44 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 45 | 46 | /* Module Resolution Options */ 47 | "moduleResolution": "nodenext" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true /* Skip type checking of declaration files. */, 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | } 72 | } 73 | --------------------------------------------------------------------------------