├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.config.ts ├── eslint.config.js ├── helpers.d.ts ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── scripts └── vendor.ts ├── src ├── babel.ts ├── builders.ts ├── code.ts ├── error.ts ├── format.ts ├── helpers.ts ├── helpers │ ├── config.ts │ ├── deep-merge.ts │ ├── index.ts │ ├── nuxt.ts │ └── vite.ts ├── index.ts ├── proxy │ ├── _utils.ts │ ├── array.ts │ ├── arrow-function-expression.ts │ ├── exports.ts │ ├── function-call.ts │ ├── identifier.ts │ ├── imports.ts │ ├── logical-expression.ts │ ├── member-expression.ts │ ├── module.ts │ ├── new-expression.ts │ ├── object.ts │ ├── proxify.ts │ └── types.ts └── types.ts ├── test ├── _utils.ts ├── array.test.ts ├── builders │ ├── expression.test.ts │ ├── function-call.test.ts │ └── raw.test.ts ├── code.test.ts ├── errors.test.ts ├── exports.test.ts ├── format.test.ts ├── function-call.test.ts ├── general.test.ts ├── helpers │ ├── nuxt.test.ts │ └── vite.test.ts ├── imports.test.ts ├── object.test.ts ├── stubs │ ├── .gitignore │ └── config.ts └── utils.test.ts ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 18 20 | cache: "pnpm" 21 | - run: pnpm install 22 | - run: pnpm build 23 | - run: pnpm lint 24 | - run: pnpm run test --coverage 25 | - run: pnpm run test:build 26 | - uses: codecov/codecov-action@v4 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | types 5 | .vscode 6 | .DS_Store 7 | .eslintcache 8 | *.log* 9 | *.env* 10 | .idea 11 | .history 12 | vendor 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.3.5 4 | 5 | [compare changes](https://github.com/unjs/magicast/compare/v0.3.4...v0.3.5) 6 | 7 | ### 🚀 Enhancements 8 | 9 | - Add `$prepend` and `$append` APIs to `imports` ([#124](https://github.com/unjs/magicast/pull/124)) 10 | 11 | ### 📦 Build 12 | 13 | - Fix types exports ([#123](https://github.com/unjs/magicast/pull/123)) 14 | 15 | ### 🏡 Chore 16 | 17 | - Update deps ([d800ae3](https://github.com/unjs/magicast/commit/d800ae3)) 18 | 19 | ### ❤️ Contributors 20 | 21 | - Anthony Fu 22 | - Bjorn Lu 23 | 24 | ## v0.3.4 25 | 26 | [compare changes](https://github.com/unjs/magicast/compare/v0.3.3...v0.3.4) 27 | 28 | ### 🚀 Enhancements 29 | 30 | - Support logical and member expression ([#111](https://github.com/unjs/magicast/pull/111)) 31 | 32 | ### 🏡 Chore 33 | 34 | - Update deps ([b37096a](https://github.com/unjs/magicast/commit/b37096a)) 35 | 36 | ### ❤️ Contributors 37 | 38 | - Anthony Fu 39 | - Lucie ([@lihbr](http://github.com/lihbr)) 40 | 41 | ## v0.3.3 42 | 43 | [compare changes](https://github.com/unjs/magicast/compare/v0.3.2...v0.3.3) 44 | 45 | ### 🚀 Enhancements 46 | 47 | - Support `ArrowFunctionExpression` ([#98](https://github.com/unjs/magicast/pull/98)) 48 | 49 | ### 🏡 Chore 50 | 51 | - Update deps and test snaps ([122409f](https://github.com/unjs/magicast/commit/122409f)) 52 | 53 | ### ❤️ Contributors 54 | 55 | - Anthony Fu 56 | - Ari Perkkiö ([@AriPerkkio](http://github.com/AriPerkkio)) 57 | 58 | ## v0.3.2 59 | 60 | [compare changes](https://github.com/unjs/magicast/compare/v0.3.1...v0.3.2) 61 | 62 | ### 🩹 Fixes 63 | 64 | - Quoted properties of `ObjectExpression` not in exports proxy ([#94](https://github.com/unjs/magicast/pull/94)) 65 | 66 | ### 🏡 Chore 67 | 68 | - Rebuild lock ([32efceb](https://github.com/unjs/magicast/commit/32efceb)) 69 | 70 | ### ❤️ Contributors 71 | 72 | - Anthony Fu 73 | - Ari Perkkiö ([@AriPerkkio](http://github.com/AriPerkkio)) 74 | 75 | ## v0.3.1 76 | 77 | [compare changes](https://github.com/unjs/magicast/compare/v0.3.0...v0.3.1) 78 | 79 | ### 🩹 Fixes 80 | 81 | - Unable to get value using `NumericLiteral` or `BooleanLiteral` type keys ([#91](https://github.com/unjs/magicast/pull/91)) 82 | 83 | ### 🏡 Chore 84 | 85 | - Update deps ([71bd811](https://github.com/unjs/magicast/commit/71bd811)) 86 | 87 | ### ❤️ Contributors 88 | 89 | - Anthony Fu 90 | - Pink Champagne 91 | 92 | ## v0.3.0 93 | 94 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.11...v0.3.0) 95 | 96 | ### 🩹 Fixes 97 | 98 | - ⚠️ `writeFile` now requires filename ([#79](https://github.com/unjs/magicast/pull/79)) 99 | 100 | ### 📦 Build 101 | 102 | - Bundle recast ([#81](https://github.com/unjs/magicast/pull/81)) 103 | 104 | ### 🏡 Chore 105 | 106 | - **release:** V0.2.11 ([0d65b23](https://github.com/unjs/magicast/commit/0d65b23)) 107 | 108 | #### ⚠️ Breaking Changes 109 | 110 | - ⚠️ `writeFile` now requires filename ([#79](https://github.com/unjs/magicast/pull/79)) 111 | 112 | ### ❤️ Contributors 113 | 114 | - Estéban ([@Barbapapazes](http://github.com/Barbapapazes)) 115 | - Anthony Fu 116 | 117 | ## v0.2.11 118 | 119 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.10...v0.2.11) 120 | 121 | ### 🚀 Enhancements 122 | 123 | - **helpers:** Handle Vite config declarations with `satisfies` keyword ([#82](https://github.com/unjs/magicast/pull/82)) 124 | 125 | ### 🏡 Chore 126 | 127 | - **release:** V0.2.10 ([4faf487](https://github.com/unjs/magicast/commit/4faf487)) 128 | - Use v8 coverage ([c1277a7](https://github.com/unjs/magicast/commit/c1277a7)) 129 | - Update deps, lint with new prettier ([30df539](https://github.com/unjs/magicast/commit/30df539)) 130 | - Update tsconfig ([02743d3](https://github.com/unjs/magicast/commit/02743d3)) 131 | - Lint ([4601589](https://github.com/unjs/magicast/commit/4601589)) 132 | 133 | ### 🤖 CI 134 | 135 | - Update lint order ([5a5d49d](https://github.com/unjs/magicast/commit/5a5d49d)) 136 | 137 | ### ❤️ Contributors 138 | 139 | - Lukas Stracke 140 | - Anthony Fu 141 | 142 | ## v0.2.10 143 | 144 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.9...v0.2.10) 145 | 146 | ### 🚀 Enhancements 147 | 148 | - **helpers:** Handle Vite config objects declared as variables in `addVitePlugin` ([#69](https://github.com/unjs/magicast/pull/69)) 149 | 150 | ### 🩹 Fixes 151 | 152 | - **object:** Handle nested keys kebab-case style ([#71](https://github.com/unjs/magicast/pull/71)) 153 | 154 | ### 🏡 Chore 155 | 156 | - Update deps ([15d091e](https://github.com/unjs/magicast/commit/15d091e)) 157 | - **release:** V0.2.9 ([d9ef2eb](https://github.com/unjs/magicast/commit/d9ef2eb)) 158 | - Update deps ([fd297f0](https://github.com/unjs/magicast/commit/fd297f0)) 159 | 160 | ### ❤️ Contributors 161 | 162 | - Anthony Fu 163 | - Lukas Stracke 164 | - Baptiste Leproux 165 | 166 | ## v0.2.9 167 | 168 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.8...v0.2.9) 169 | 170 | ### 🚀 Enhancements 171 | 172 | - DeepMergeObject improvements; NumericLiteral and StringLiteral keys ([#63](https://github.com/unjs/magicast/pull/63)) 173 | 174 | ### 🏡 Chore 175 | 176 | - Update deps ([15d091e](https://github.com/unjs/magicast/commit/15d091e)) 177 | 178 | ### ❤️ Contributors 179 | 180 | - Anthony Fu 181 | - Yaël Guilloux ([@Tahul](http://github.com/Tahul)) 182 | 183 | ## v0.2.8 184 | 185 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.7...v0.2.8) 186 | 187 | ### 🩹 Fixes 188 | 189 | - Type resolution in node16 environments ([#60](https://github.com/unjs/magicast/pull/60)) 190 | - **helpers:** Improve deepMergeObject handling case ([#62](https://github.com/unjs/magicast/pull/62)) 191 | 192 | ### 🏡 Chore 193 | 194 | - **release:** V0.2.7 ([c719013](https://github.com/unjs/magicast/commit/c719013)) 195 | - Typo ([#59](https://github.com/unjs/magicast/pull/59)) 196 | 197 | ### ❤️ Contributors 198 | 199 | - Yaël Guilloux ([@Tahul](http://github.com/Tahul)) 200 | - Samuel Stroschein 201 | - Igor Babko ([@igorbabko](http://github.com/igorbabko)) 202 | - Anthony Fu 203 | 204 | ## v0.2.7 205 | 206 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.6...v0.2.7) 207 | 208 | ### 🩹 Fixes 209 | 210 | - **createProxy:** Trap for the 'in' operator ([#58](https://github.com/unjs/magicast/pull/58)) 211 | 212 | ### ❤️ Contributors 213 | 214 | - Zoeyzhao19 215 | 216 | ## v0.2.6 217 | 218 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.5...v0.2.6) 219 | 220 | ### 🩹 Fixes 221 | 222 | - Proxy sub module types ([3251584](https://github.com/unjs/magicast/commit/3251584)) 223 | 224 | ### 🏡 Chore 225 | 226 | - Lint ([b32604b](https://github.com/unjs/magicast/commit/b32604b)) 227 | 228 | ### ❤️ Contributors 229 | 230 | - Anthony Fu 231 | 232 | ## v0.2.5 233 | 234 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.4...v0.2.5) 235 | 236 | ### 🚀 Enhancements 237 | 238 | - Add Vite plugin at a given index ([#53](https://github.com/unjs/magicast/pull/53)) 239 | 240 | ### 🩹 Fixes 241 | 242 | - Support code with `as` and `satisfies` ([#55](https://github.com/unjs/magicast/pull/55)) 243 | 244 | ### 🏡 Chore 245 | 246 | - Update release script ([602c25d](https://github.com/unjs/magicast/commit/602c25d)) 247 | - Import type ([#50](https://github.com/unjs/magicast/pull/50)) 248 | - Update format ([48be33a](https://github.com/unjs/magicast/commit/48be33a)) 249 | - Update script ([e21055b](https://github.com/unjs/magicast/commit/e21055b)) 250 | 251 | ### ❤️ Contributors 252 | 253 | - Anthony Fu 254 | - Lukas Stracke 255 | - LiuSeen 256 | - Mateusz Burzyński ([@Andarist](http://github.com/Andarist)) 257 | 258 | ## v0.2.4 259 | 260 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.3...v0.2.4) 261 | 262 | ### 🩹 Fixes 263 | 264 | - Enumerable keys for `exports` and `imports`, close #38 ([#38](https://github.com/unjs/magicast/issues/38)) 265 | - Make proxied module enumerable, close #47 ([#47](https://github.com/unjs/magicast/issues/47)) 266 | 267 | ### 📖 Documentation 268 | 269 | - Add notes about usage ([5c5cd52](https://github.com/unjs/magicast/commit/5c5cd52)) 270 | 271 | ### 🏡 Chore 272 | 273 | - **release:** V0.2.3 ([f8dc796](https://github.com/unjs/magicast/commit/f8dc796)) 274 | - Update deps ([3de0c61](https://github.com/unjs/magicast/commit/3de0c61)) 275 | - Update deps ([cf0e6cb](https://github.com/unjs/magicast/commit/cf0e6cb)) 276 | - Add lint-staged ([7fa88fc](https://github.com/unjs/magicast/commit/7fa88fc)) 277 | 278 | ### ✅ Tests 279 | 280 | - Add identifier as property example ([fb77b5d](https://github.com/unjs/magicast/commit/fb77b5d)) 281 | 282 | ### ❤️ Contributors 283 | 284 | - Anthony Fu 285 | 286 | ## v0.2.3 287 | 288 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.2...v0.2.3) 289 | 290 | ### 🩹 Fixes 291 | 292 | - Enumerable keys for `exports` and `imports`, close #38 ([#38](https://github.com/unjs/magicast/issues/38)) 293 | 294 | ### ❤️ Contributors 295 | 296 | - Anthony Fu 297 | 298 | ## v0.2.2 299 | 300 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.1...v0.2.2) 301 | 302 | ### 🚀 Enhancements 303 | 304 | - Add identifier casting ([#39](https://github.com/unjs/magicast/pull/39)) 305 | 306 | ### ❤️ Contributors 307 | 308 | - Hugo ATTAL 309 | 310 | ## v0.2.1 311 | 312 | [compare changes](https://github.com/unjs/magicast/compare/v0.2.0...v0.2.1) 313 | 314 | ### 🚀 Enhancements 315 | 316 | - Support `builder.raw` ([4983f47](https://github.com/unjs/magicast/commit/4983f47)) 317 | - Support `builder.newExpression` ([cf5ad6d](https://github.com/unjs/magicast/commit/cf5ad6d)) 318 | 319 | ### 📖 Documentation 320 | 321 | - Add some examples ([3106c32](https://github.com/unjs/magicast/commit/3106c32)) 322 | - Typo ([#32](https://github.com/unjs/magicast/pull/32)) 323 | 324 | ### ❤️ Contributors 325 | 326 | - Anthony Fu 327 | - Betteroneday 328 | 329 | ## v0.2.0 330 | 331 | [compare changes](https://github.com/unjs/magicast/compare/v0.1.1...v0.2.0) 332 | 333 | ### 🚀 Enhancements 334 | 335 | - Support delete operation ([ad40a7b](https://github.com/unjs/magicast/commit/ad40a7b)) 336 | - Support more array operation ([90040ee](https://github.com/unjs/magicast/commit/90040ee)) 337 | - Use proxy for top level module ([#8](https://github.com/unjs/magicast/pull/8)) 338 | - `imports` support ([#11](https://github.com/unjs/magicast/pull/11)) 339 | - Support Date, Set, and Map to `literalToAst` ([b97d8f2](https://github.com/unjs/magicast/commit/b97d8f2)) 340 | - Automatically preserve code style ([#10](https://github.com/unjs/magicast/pull/10)) 341 | - Improve error system ([4a286e2](https://github.com/unjs/magicast/commit/4a286e2)) 342 | - Construct function call ([#15](https://github.com/unjs/magicast/pull/15)) 343 | - Improve typescript support ([9d9bd43](https://github.com/unjs/magicast/commit/9d9bd43)) 344 | - Support `mod.generate` ([b27e2b5](https://github.com/unjs/magicast/commit/b27e2b5)) 345 | - ⚠️ `parseModule` and `parseExpression` ([#24](https://github.com/unjs/magicast/pull/24)) 346 | - Add high level helpers ([912c135](https://github.com/unjs/magicast/commit/912c135)) 347 | 348 | ### 🔥 Performance 349 | 350 | - Cache proxify ([949ec48](https://github.com/unjs/magicast/commit/949ec48)) 351 | 352 | ### 🩹 Fixes 353 | 354 | - Improve edge cases of `literalToAst` ([f9b6296](https://github.com/unjs/magicast/commit/f9b6296)) 355 | 356 | ### 💅 Refactors 357 | 358 | - ⚠️ Rename `.arguments` to `.$args` for functions ([#7](https://github.com/unjs/magicast/pull/7)) 359 | - Use `@babel/types` over `estree` ([308fd21](https://github.com/unjs/magicast/commit/308fd21)) 360 | - Split test files ([dcc759e](https://github.com/unjs/magicast/commit/dcc759e)) 361 | - Break down test files ([5af3f8c](https://github.com/unjs/magicast/commit/5af3f8c)) 362 | - Break down files ([fecdee1](https://github.com/unjs/magicast/commit/fecdee1)) 363 | - ⚠️ Rename `builder` to `builders` ([0dd8e9a](https://github.com/unjs/magicast/commit/0dd8e9a)) 364 | 365 | ### 📖 Documentation 366 | 367 | - Update usage ([51a82eb](https://github.com/unjs/magicast/commit/51a82eb)) 368 | - Update ([#19](https://github.com/unjs/magicast/pull/19)) 369 | - Update badges ([#26](https://github.com/unjs/magicast/pull/26)) 370 | 371 | ### 🏡 Chore 372 | 373 | - Fix type errors ([effae7c](https://github.com/unjs/magicast/commit/effae7c)) 374 | - Lint ([c58699b](https://github.com/unjs/magicast/commit/c58699b)) 375 | - Fix typo ([fa3ce99](https://github.com/unjs/magicast/commit/fa3ce99)) 376 | - **readme:** Fix space ([#23](https://github.com/unjs/magicast/pull/23)) 377 | - Update deps ([8185270](https://github.com/unjs/magicast/commit/8185270)) 378 | - Fix build ([89772a5](https://github.com/unjs/magicast/commit/89772a5)) 379 | 380 | #### ⚠️ Breaking Changes 381 | 382 | - ⚠️ `parseModule` and `parseExpression` ([#24](https://github.com/unjs/magicast/pull/24)) 383 | - ⚠️ Rename `.arguments` to `.$args` for functions ([#7](https://github.com/unjs/magicast/pull/7)) 384 | - ⚠️ Rename `builder` to `builders` ([0dd8e9a](https://github.com/unjs/magicast/commit/0dd8e9a)) 385 | 386 | ### ❤️ Contributors 387 | 388 | - Anthony Fu 389 | - Sébastien Chopin 390 | - Alexander Lichter ([@manniL](http://github.com/manniL)) 391 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 392 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Pooya Parsa and Anthony Fu 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 | # 🧀 Magicast 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![bundle][bundle-src]][bundle-href] 6 | [![Codecov][codecov-src]][codecov-href] 7 | [![License][license-src]][license-href] 8 | [![JSDocs][jsdocs-src]][jsdocs-href] 9 | 10 | Programmatically modify JavaScript and TypeScript source codes with a simplified, elegant and familiar syntax. Built on top of the [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree) parsed by [recast](https://github.com/benjamn/recast) and [babel](https://babeljs.io/). 11 | 12 | ❯ 🧙🏼 **Magical** modify a JS/TS file and write back magically just like JSON!
13 | ❯ 🔀 **Exports/Import** manipulate module's imports and exports at ease
14 | ❯ 💼 **Function Arguments** easily manipulate arguments passed to a function call, like `defineConfig()`
15 | ❯ 🎨 **Smart Formatting** preseves the formatting style (quotes, tabs, etc.) from the original code
16 | ❯ 🧑‍💻 **Readable** get rid of the complexity of AST manipulation and make your code super readable
17 | 18 | ## Install 19 | 20 | Install npm package: 21 | 22 | ```sh 23 | # using yarn 24 | yarn add --dev magicast 25 | 26 | # using npm 27 | npm install -D magicast 28 | 29 | # using pnpm 30 | pnpm add -D magicast 31 | ``` 32 | 33 | Import utilities: 34 | 35 | ```js 36 | // ESM / Bundler 37 | import { parseModule, generateCode, builders, createNode } from "magicast"; 38 | 39 | // CommonJS 40 | const { parseModule, generateCode, builders, createNode } = require("magicast"); 41 | ``` 42 | 43 | ## Examples 44 | 45 | **Example:** Modify a file: 46 | 47 | `config.js`: 48 | 49 | ```js 50 | export default { 51 | foo: ["a"], 52 | }; 53 | ``` 54 | 55 | Code to modify and append `b` to `foo` prop of defaultExport: 56 | 57 | ```js 58 | import { loadFile, writeFile } from "magicast"; 59 | 60 | const mod = await loadFile("config.js"); 61 | 62 | mod.exports.default.foo.push("b"); 63 | 64 | await writeFile(mod, "config.js"); 65 | ``` 66 | 67 | Updated `config.js`: 68 | 69 | ```js 70 | export default { 71 | foo: ["a", "b"], 72 | }; 73 | ``` 74 | 75 | **Example:** Directly use AST utils: 76 | 77 | ```js 78 | import { parseModule, generateCode } from "magicast"; 79 | 80 | // Parse to AST 81 | const mod = parseModule(`export default { }`); 82 | 83 | // Ensure foo is an array 84 | mod.exports.default.foo ||= []; 85 | // Add a new array member 86 | mod.exports.default.foo.push("b"); 87 | mod.exports.default.foo.unshift("a"); 88 | 89 | // Generate code 90 | const { code, map } = generateCode(mod); 91 | ``` 92 | 93 | Generated code: 94 | 95 | ```js 96 | export default { 97 | foo: ["a", "b"], 98 | }; 99 | ``` 100 | 101 | **Example:** Get the AST directly: 102 | 103 | ```js 104 | import { parseModule, generateCode } from "magicast"; 105 | 106 | const mod = parseModule(`export default { }`); 107 | 108 | const ast = mod.exports.default.$ast; 109 | // do something with ast 110 | ``` 111 | 112 | **Example:** Function arguments: 113 | 114 | ```js 115 | import { parseModule, generateCode } from "magicast"; 116 | 117 | const mod = parseModule(`export default defineConfig({ foo: 'bar' })`); 118 | 119 | // Support for both bare object export and `defineConfig` wrapper 120 | const options = 121 | mod.exports.default.$type === "function-call" 122 | ? mod.exports.default.$args[0] 123 | : mod.exports.default; 124 | 125 | console.log(options.foo); // bar 126 | ``` 127 | 128 | **Example:** Create a function call: 129 | 130 | ```js 131 | import { parseModule, generateCode, builders } from "magicast"; 132 | 133 | const mod = parseModule(`export default {}`); 134 | 135 | const options = (mod.exports.default.list = builders.functionCall( 136 | "create", 137 | [1, 2, 3], 138 | )); 139 | 140 | console.log(mod.generateCode()); // export default { list: create([1, 2, 3]) } 141 | ``` 142 | 143 | ## Notes 144 | 145 | As JavaScript is a very dynamic language, you should be aware that Magicast's convention **CAN NOT cover all possible cases**. Magicast serves as a simple and maintainable interface to update static-ish JavaScript code. When interacting with Magicast node, be aware that every option might have chance to throw an error depending on the input code. We recommend to always wrap the code in a `try/catch` block (even better to do some defensive coding), for example: 146 | 147 | ```ts 148 | import { loadFile, writeFile } from "magicast"; 149 | 150 | function updateConfig() { 151 | try { 152 | const mod = await loadFile("config.js"); 153 | 154 | mod.exports.default.foo.push("b"); 155 | 156 | await writeFile(mod); 157 | } catch { 158 | console.error("Unable to update config.js"); 159 | console.error( 160 | "Please update it manually with the following instructions: ...", 161 | ); 162 | // handle error 163 | } 164 | } 165 | ``` 166 | 167 | ## High Level Helpers 168 | 169 | We also experiment to provide a few high level helpers to make common tasks easier. You could import them from `magicast/helpers`. They might be moved to a separate package in the future. 170 | 171 | ```js 172 | import { 173 | deepMergeObject, 174 | addNuxtModule, 175 | addVitePlugin, 176 | // ... 177 | } from "magicast/helpers"; 178 | ``` 179 | 180 | We recommend to check out the [source code](./src/helpers) and [test cases](./test/helpers) for more details. 181 | 182 | ## Development 183 | 184 | - Clone this repository 185 | - Install latest LTS version of [Node.js](https://nodejs.org/en/) 186 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 187 | - Install dependencies using `pnpm install` 188 | - Run interactive tests using `pnpm dev` 189 | 190 | ## License 191 | 192 | Made with 💛 193 | 194 | Published under [MIT License](./LICENSE). 195 | 196 | 197 | 198 | [npm-version-src]: https://img.shields.io/npm/v/magicast?style=flat&colorA=18181B&colorB=F0DB4F 199 | [npm-version-href]: https://npmjs.com/package/magicast 200 | [npm-downloads-src]: https://img.shields.io/npm/dm/magicast?style=flat&colorA=18181B&colorB=F0DB4F 201 | [npm-downloads-href]: https://npmjs.com/package/magicast 202 | [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/magicast/main?style=flat&colorA=18181B&colorB=F0DB4F 203 | [codecov-href]: https://codecov.io/gh/unjs/magicast 204 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/magicast?style=flat&colorA=18181B&colorB=F0DB4F 205 | [bundle-href]: https://bundlephobia.com/result?p=magicast 206 | [license-src]: https://img.shields.io/github/license/unjs/magicast.svg?style=flat&colorA=18181B&colorB=F0DB4F 207 | [license-href]: https://github.com/unjs/magicast/blob/main/LICENSE 208 | [jsdocs-src]: https://img.shields.io/badge/jsDocs.io-reference-18181B?style=flat&colorA=18181B&colorB=F0DB4F 209 | [jsdocs-href]: https://www.jsdocs.io/package/magicast 210 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { defineBuildConfig } from "unbuild"; 3 | 4 | export default defineBuildConfig({ 5 | entries: ["src/index", "src/helpers"], 6 | externals: ["@babel/types"], 7 | declaration: true, 8 | rollup: { 9 | emitCJS: true, 10 | inlineDependencies: true, 11 | dts: { 12 | respectExternal: true, 13 | }, 14 | }, 15 | alias: { 16 | "source-map": "source-map-js", 17 | 18 | recast: resolve(__dirname, "vendor/recast/main.ts"), 19 | 20 | "ast-types": resolve(__dirname, "vendor/ast-types/src/main.ts"), 21 | }, 22 | hooks: { 23 | "rollup:dts:options": (ctx, options) => { 24 | // @ts-expect-error filter out commonjs plugin in dts build 25 | options.plugins = options.plugins.filter((plugin) => { 26 | return plugin && plugin.name !== "commonjs"; 27 | }); 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | export default unjs( 4 | { 5 | rules: { 6 | "no-useless-constructor": 0, 7 | "unicorn/empty-brace-spaces": 0, 8 | "unicorn/expiring-todo-comments": 0, 9 | }, 10 | }, 11 | { 12 | ignores: ["vendor/**/*"], 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /helpers.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./dist/helpers.js"; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magicast", 3 | "version": "0.3.5", 4 | "description": "Modify a JS/TS file and write back magically just like JSON!", 5 | "repository": "unjs/magicast", 6 | "license": "MIT", 7 | "sideEffects": false, 8 | "type": "module", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.mjs", 12 | "require": "./dist/index.cjs" 13 | }, 14 | "./helpers": { 15 | "import": "./dist/helpers.mjs", 16 | "require": "./dist/helpers.cjs" 17 | } 18 | }, 19 | "main": "./dist/index.cjs", 20 | "module": "./dist/index.mjs", 21 | "types": "./dist/index.d.ts", 22 | "files": [ 23 | "dist", 24 | "*.d.ts" 25 | ], 26 | "scripts": { 27 | "build": "unbuild", 28 | "prepare": "esno ./scripts/vendor.ts", 29 | "dev": "vitest dev", 30 | "dev:ui": "vitest dev --ui", 31 | "lint": "eslint --cache . && prettier -c .", 32 | "lint:fix": "eslint --cache . --fix && prettier -c . -w", 33 | "prepack": "pnpm run build", 34 | "typecheck": "tsc --noEmit", 35 | "release": "pnpm run test run && changelogen --release && npm publish && git push --follow-tags", 36 | "test": "vitest", 37 | "test:build": "TEST_BUILD=true vitest", 38 | "test:full": "pnpm run test --run && pnpm run build && pnpm run test:build --run" 39 | }, 40 | "dependencies": { 41 | "@babel/parser": "^7.25.4", 42 | "@babel/types": "^7.25.4", 43 | "source-map-js": "^1.2.0" 44 | }, 45 | "devDependencies": { 46 | "@types/node": "^20.16.1", 47 | "@vitest/coverage-v8": "^1.6.0", 48 | "@vitest/ui": "^1.6.0", 49 | "ast-types": "^0.16.1", 50 | "changelogen": "^0.5.5", 51 | "eslint": "^9.9.1", 52 | "eslint-config-unjs": "^0.3.2", 53 | "esno": "^4.7.0", 54 | "giget": "^1.2.3", 55 | "lint-staged": "^15.2.9", 56 | "magicast": "workspace:*", 57 | "prettier": "^3.3.3", 58 | "recast": "^0.23.9", 59 | "simple-git-hooks": "^2.11.1", 60 | "source-map": "npm:source-map-js@latest", 61 | "typescript": "^5.5.4", 62 | "unbuild": "^2.0.0", 63 | "vitest": "^1.6.0" 64 | }, 65 | "resolutions": { 66 | "source-map": "npm:source-map-js@latest" 67 | }, 68 | "simple-git-hooks": { 69 | "pre-commit": "pnpm lint-staged" 70 | }, 71 | "lint-staged": { 72 | "*.{ts,js,mjs,cjs}": [ 73 | "eslint --fix", 74 | "prettier -w" 75 | ] 76 | }, 77 | "packageManager": "pnpm@8.15.9", 78 | "pnpm": { 79 | "overrides": { 80 | "array-includes": "npm:@nolyfill/array-includes@latest", 81 | "array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@latest", 82 | "array.prototype.flat": "npm:@nolyfill/array.prototype.flat@latest", 83 | "array.prototype.flatmap": "npm:@nolyfill/array.prototype.flatmap@latest", 84 | "hasown": "npm:@nolyfill/hasown@latest", 85 | "object.fromentries": "npm:@nolyfill/object.fromentries@latest", 86 | "object.groupby": "npm:@nolyfill/object.groupby@latest", 87 | "object.values": "npm:@nolyfill/object.values@latest" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "!vendor/*" 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /scripts/vendor.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import fsp from "node:fs/promises"; 3 | import { join } from "node:path"; 4 | import { downloadTemplate } from "giget"; 5 | 6 | // This script clones recast and patches, and then re-bundle it so we get rid of the unnecessary polyfills 7 | 8 | async function cloneRecast() { 9 | if (fs.existsSync("vendor/recast")) { 10 | console.log("vendor/recast already exists"); 11 | } else { 12 | // Clone recast 13 | await downloadTemplate("github:benjamn/recast#v0.23.4", { 14 | dir: "vendor/recast", 15 | }); 16 | 17 | // Remove the tsconfig.json so it's targeting newer node versions 18 | await fsp.rm("vendor/recast/tsconfig.json"); 19 | 20 | // Remove the assert import and usage 21 | await Promise.all( 22 | fs 23 | .readdirSync("vendor/recast/lib", { withFileTypes: true }) 24 | .map(async (file) => { 25 | if (!file.isFile()) { 26 | return; 27 | } 28 | return await filterLines(join(file.path, file.name), (line) => { 29 | if (line.startsWith("import assert from")) { 30 | return false; 31 | } 32 | if (/^\s*assert\./.test(line)) { 33 | if (line.endsWith(";")) { 34 | return false; 35 | } 36 | return `// @ts-ignore \n false && ` + line; 37 | } 38 | return line; 39 | }); 40 | }), 41 | ); 42 | 43 | // Remove the require(), and since we are providing our own parser anyway 44 | await filterLines("vendor/recast/lib/options.ts", (line) => { 45 | if (line.includes('parser: require("../parsers/esprima")')) { 46 | return false; 47 | } 48 | return line; 49 | }); 50 | 51 | await filterLines("vendor/recast/lib/parser.ts", (line) => { 52 | return line.replace('require("esprima")', `false && require("")`); 53 | }); 54 | 55 | await filterLines("vendor/recast/lib/util.ts", (line) => { 56 | if (line.includes(String.raw`isBrowser() ? "\n"`)) { 57 | return String.raw`return "\n"`; 58 | } 59 | return line; 60 | }); 61 | 62 | console.log("vendor/recast cloned"); 63 | } 64 | } 65 | 66 | async function cloneAstTypes() { 67 | if (fs.existsSync("vendor/ast-types")) { 68 | console.log("vendor/ast-types already exists"); 69 | } else { 70 | // Clone recast 71 | await downloadTemplate("github:benjamn/ast-types#v0.16.1", { 72 | dir: "vendor/ast-types", 73 | }); 74 | 75 | // Remove the tsconfig.json so it's targeting newer node versions 76 | await fsp.rm("vendor/ast-types/tsconfig.json"); 77 | 78 | // Add import type 79 | await filterLines("vendor/ast-types/src/main.ts", (line) => { 80 | if (/^import\s*{\s*(ASTNode|Visitor)/.test(line)) { 81 | return line.replace(/^import /, "import type "); 82 | } 83 | return line; 84 | }); 85 | 86 | console.log("vendor/ast-types cloned"); 87 | } 88 | } 89 | 90 | async function filterLines( 91 | file: string, 92 | filter: (line: string, index: number) => boolean | string, 93 | ) { 94 | const content = await fsp.readFile(file, "utf8"); 95 | const lines = content.split("\n"); 96 | const newContent = lines 97 | .map((i, idx) => filter(i, idx)) 98 | .filter((i) => i !== false) 99 | .join("\n"); 100 | if (newContent !== content) { 101 | await fsp.writeFile(file, newContent); 102 | } 103 | } 104 | 105 | await Promise.all([cloneRecast(), cloneAstTypes()]); 106 | -------------------------------------------------------------------------------- /src/babel.ts: -------------------------------------------------------------------------------- 1 | import * as babelParser from "@babel/parser"; 2 | import type { ParserOptions, ParserPlugin } from "@babel/parser"; 3 | 4 | let _babelParser: { parse: typeof babelParser.parse } | undefined; 5 | 6 | export function getBabelParser() { 7 | if (_babelParser) { 8 | return _babelParser; 9 | } 10 | const babelOptions = _getBabelOptions(); 11 | _babelParser = { 12 | parse(source: string, options?: ParserOptions) { 13 | return babelParser.parse(source, { 14 | ...babelOptions, 15 | ...options, 16 | }) as any; 17 | }, 18 | }; 19 | return _babelParser; 20 | } 21 | 22 | function _getBabelOptions(): ParserOptions & { plugins: ParserPlugin[] } { 23 | // The goal here is to tolerate as much syntax as possible, since Recast 24 | // is not in the business of forbidding anything. If you want your 25 | // parser to be more restrictive for some reason, you can always pass 26 | // your own parser object to recast.parse. 27 | return { 28 | sourceType: "module", 29 | strictMode: false, 30 | allowImportExportEverywhere: true, 31 | allowReturnOutsideFunction: true, 32 | startLine: 1, 33 | tokens: true, 34 | plugins: [ 35 | "asyncGenerators", 36 | "bigInt", 37 | "classPrivateMethods", 38 | "classPrivateProperties", 39 | "classProperties", 40 | "classStaticBlock", 41 | "decimal", 42 | "decorators-legacy", 43 | "doExpressions", 44 | "dynamicImport", 45 | "exportDefaultFrom", 46 | "exportExtensions" as any as ParserPlugin, 47 | "exportNamespaceFrom", 48 | "functionBind", 49 | "functionSent", 50 | "importAssertions", 51 | "importMeta", 52 | "nullishCoalescingOperator", 53 | "numericSeparator", 54 | "objectRestSpread", 55 | "optionalCatchBinding", 56 | "optionalChaining", 57 | [ 58 | "pipelineOperator", 59 | { 60 | proposal: "minimal", 61 | }, 62 | ] as any as ParserPlugin, 63 | [ 64 | "recordAndTuple", 65 | { 66 | syntaxType: "hash", 67 | }, 68 | ], 69 | "throwExpressions", 70 | "topLevelAwait", 71 | "v8intrinsic", 72 | "jsx", 73 | "typescript", 74 | ], 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/builders.ts: -------------------------------------------------------------------------------- 1 | import * as recast from "recast"; 2 | import { proxifyFunctionCall } from "./proxy/function-call"; 3 | import { proxifyNewExpression } from "./proxy/new-expression"; 4 | import { literalToAst } from "./proxy/_utils"; 5 | import type { Proxified } from "./types"; 6 | import { parseExpression } from "./code"; 7 | 8 | const b = recast.types.builders; 9 | 10 | export const builders = { 11 | /** 12 | * Create a function call node. 13 | */ 14 | functionCall(callee: string, ...args: any[]): Proxified { 15 | const node = b.callExpression( 16 | b.identifier(callee), 17 | args.map((i) => literalToAst(i) as any), 18 | ); 19 | return proxifyFunctionCall(node as any); 20 | }, 21 | /** 22 | * Create a new expression node. 23 | */ 24 | newExpression(callee: string, ...args: any[]): Proxified { 25 | const node = b.newExpression( 26 | b.identifier(callee), 27 | args.map((i) => literalToAst(i) as any), 28 | ); 29 | return proxifyNewExpression(node as any); 30 | }, 31 | /** 32 | * Create a proxified version of a literal value. 33 | */ 34 | literal(value: any): Proxified { 35 | return literalToAst(value); 36 | }, 37 | /** 38 | * Parse a raw expression and return a proxified version of it. 39 | * 40 | * ```ts 41 | * const obj = builders.raw("{ foo: 1 }"); 42 | * console.log(obj.foo); // 1 43 | * ``` 44 | */ 45 | raw(code: string): Proxified { 46 | return parseExpression(code); 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/code.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from "node:fs"; 2 | import { print, parse, Options as ParseOptions } from "recast"; 3 | import { getBabelParser } from "./babel"; 4 | import { 5 | ASTNode, 6 | GenerateOptions, 7 | ParsedFileNode, 8 | Proxified, 9 | ProxifiedModule, 10 | } from "./types"; 11 | import { proxifyModule } from "./proxy/module"; 12 | import { detectCodeFormat } from "./format"; 13 | import { proxify } from "./proxy/proxify"; 14 | 15 | export function parseModule( 16 | code: string, 17 | options?: ParseOptions, 18 | ): ProxifiedModule { 19 | const node: ParsedFileNode = parse(code, { 20 | parser: options?.parser || getBabelParser(), 21 | ...options, 22 | }); 23 | return proxifyModule(node, code); 24 | } 25 | 26 | export function parseExpression( 27 | code: string, 28 | options?: ParseOptions, 29 | ): Proxified { 30 | const root: ParsedFileNode = parse("(" + code + ")", { 31 | parser: options?.parser || getBabelParser(), 32 | ...options, 33 | }); 34 | let body: ASTNode = root.program.body[0]; 35 | if (body.type === "ExpressionStatement") { 36 | body = body.expression; 37 | } 38 | if (body.extra?.parenthesized) { 39 | body.extra.parenthesized = false; 40 | } 41 | 42 | const mod = { 43 | $ast: root, 44 | $code: " " + code + " ", 45 | $type: "module", 46 | } as any as ProxifiedModule; 47 | 48 | return proxify(body, mod); 49 | } 50 | 51 | export function generateCode( 52 | node: { $ast: ASTNode } | ASTNode | ProxifiedModule, 53 | options: GenerateOptions = {}, 54 | ): { code: string; map?: any } { 55 | const ast = (node as Proxified).$ast || node; 56 | 57 | const formatOptions = 58 | options.format === false || !("$code" in node) 59 | ? {} 60 | : detectCodeFormat(node.$code, options.format); 61 | 62 | const { code, map } = print(ast, { 63 | ...options, 64 | ...formatOptions, 65 | }); 66 | 67 | return { code, map }; 68 | } 69 | 70 | export async function loadFile( 71 | filename: string, 72 | options: ParseOptions = {}, 73 | ): Promise> { 74 | const contents = await fsp.readFile(filename, "utf8"); 75 | options.sourceFileName = options.sourceFileName ?? filename; 76 | return parseModule(contents, options); 77 | } 78 | 79 | export async function writeFile( 80 | node: { $ast: ASTNode } | ASTNode, 81 | filename: string, 82 | options?: ParseOptions, 83 | ): Promise { 84 | const ast = "$ast" in node ? node.$ast : node; 85 | const { code, map } = generateCode(ast, options); 86 | await fsp.writeFile(filename as string, code); 87 | if (map) { 88 | await fsp.writeFile(filename + ".map", map); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode } from "./types"; 2 | 3 | export interface MagicastErrorOptions { 4 | ast?: ASTNode; 5 | code?: string; 6 | } 7 | 8 | export class MagicastError extends Error { 9 | rawMessage: string; 10 | options?: MagicastErrorOptions; 11 | 12 | constructor(message: string, options?: MagicastErrorOptions) { 13 | super(""); 14 | this.name = "MagicastError"; 15 | this.rawMessage = message; 16 | this.options = options; 17 | 18 | if (options?.ast && options?.code && options.ast.loc) { 19 | // construct a code frame point to the start of the ast node 20 | const { line, column } = options.ast.loc.start; 21 | const lines = options.code.split("\n"); 22 | const start = Math.max(0, line - 3); 23 | const end = Math.min(lines.length, line + 3); 24 | const codeFrame = lines.slice(start, end).map((lineCode, i) => { 25 | const number = (start + i + 1).toString().padStart(3, " "); 26 | lineCode = `${number} | ${lineCode}`; 27 | if (start + i === line - 1) { 28 | lineCode += `\n${" ".repeat(6 + column)}^`; 29 | } 30 | return lineCode; 31 | }); 32 | 33 | message += `\n\n${codeFrame.join("\n")}\n`; 34 | } 35 | 36 | this.message = message; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | // Extracted from recast 2 | export interface CodeFormatOptions { 3 | tabWidth?: number; 4 | useTabs?: boolean; 5 | wrapColumn?: number; 6 | quote?: "single" | "double"; 7 | trailingComma?: boolean; 8 | arrayBracketSpacing?: boolean; 9 | objectCurlySpacing?: boolean; 10 | arrowParensAlways?: boolean; 11 | useSemi?: boolean; 12 | } 13 | 14 | export function detectCodeFormat( 15 | code: string, 16 | userStyles: CodeFormatOptions = {}, 17 | ): CodeFormatOptions { 18 | // Detect features only user not specified 19 | const detect = { 20 | wrapColumn: userStyles.wrapColumn === undefined, 21 | indent: 22 | userStyles.tabWidth === undefined || userStyles.useTabs === undefined, 23 | quote: userStyles.quote === undefined, 24 | arrowParens: userStyles.arrowParensAlways === undefined, 25 | trailingComma: userStyles.trailingComma === undefined, 26 | }; 27 | 28 | // Frequency counters and state 29 | let codeIndent = 2; 30 | let tabUsages = 0; 31 | let semiUsages = 0; 32 | let maxLineLength = 0; 33 | let multiLineTrailingCommaUsages = 0; 34 | 35 | // Syntax detection regex 36 | // TODO: Perf: Compile only for features we need to detect 37 | const syntaxDetectRegex = 38 | /(?"[^"]+")|(?'[^']+')|(?\([^),]+\)\s*=>)|(?,\s*[\]}])/g; 39 | const syntaxUsages = { 40 | doubleQuote: 0, 41 | singleQuote: 0, 42 | singleParam: 0, 43 | trailingComma: 0, 44 | }; 45 | 46 | // Line by line analysis 47 | const lines = (code || "").split("\n"); 48 | let previousLineTrailing = false; 49 | for (const line of lines) { 50 | // Trim line 51 | // TODO: Trim comments 52 | const trimmitedLine = line.trim(); 53 | 54 | // Skip empty lines 55 | if (trimmitedLine.length === 0) { 56 | continue; 57 | } 58 | 59 | // Max width 60 | if (detect.wrapColumn && line.length > maxLineLength) { 61 | maxLineLength = line.length; 62 | } 63 | 64 | // Indentation analysis 65 | if (detect.indent) { 66 | const lineIndent = line.match(/^\s+/)?.[0] || ""; 67 | if (lineIndent.length > 0) { 68 | if (lineIndent.length > 0 && lineIndent.length < codeIndent) { 69 | codeIndent = lineIndent.length; 70 | } 71 | if (lineIndent[0] === "\t") { 72 | tabUsages++; 73 | } else if (lineIndent.length > 0) { 74 | tabUsages--; 75 | } 76 | } 77 | } 78 | 79 | // Line ending analysis 80 | if (trimmitedLine.at(-1) === ";") { 81 | semiUsages++; 82 | } else if (trimmitedLine.length > 0) { 83 | semiUsages--; 84 | } 85 | 86 | // Syntax analysis 87 | if (detect.quote || detect.arrowParens) { 88 | const matches = trimmitedLine.matchAll(syntaxDetectRegex); 89 | for (const match of matches) { 90 | if (!match.groups) { 91 | continue; 92 | } 93 | for (const key in syntaxUsages) { 94 | if (match.groups[key]) { 95 | // @ts-ignore 96 | syntaxUsages[key]++; 97 | } 98 | } 99 | } 100 | } 101 | 102 | if (detect.trailingComma) { 103 | if (line.startsWith("}") || line.startsWith("]")) { 104 | if (previousLineTrailing) { 105 | multiLineTrailingCommaUsages++; 106 | } else { 107 | multiLineTrailingCommaUsages--; 108 | } 109 | } 110 | previousLineTrailing = trimmitedLine.endsWith(","); 111 | } 112 | } 113 | 114 | return { 115 | wrapColumn: maxLineLength, 116 | useTabs: tabUsages > 0, 117 | tabWidth: codeIndent, 118 | quote: 119 | syntaxUsages.singleQuote > syntaxUsages.doubleQuote ? "single" : "double", 120 | arrowParensAlways: syntaxUsages.singleParam > 0, 121 | trailingComma: 122 | multiLineTrailingCommaUsages > 0 || syntaxUsages.trailingComma > 0, 123 | useSemi: semiUsages > 0, 124 | arrayBracketSpacing: undefined, // TODO 125 | objectCurlySpacing: undefined, // TODO 126 | ...userStyles, 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export * from "./helpers/index"; 2 | -------------------------------------------------------------------------------- /src/helpers/config.ts: -------------------------------------------------------------------------------- 1 | import { Program, VariableDeclarator } from "@babel/types"; 2 | import { generateCode, parseExpression } from "../code"; 3 | import { MagicastError } from "../error"; 4 | import type { Proxified, ProxifiedModule, ProxifiedObject } from "../types"; 5 | 6 | export function getDefaultExportOptions(magicast: ProxifiedModule) { 7 | return configFromNode(magicast.exports.default); 8 | } 9 | 10 | /** 11 | * Returns the vite config object from a variable declaration thats 12 | * exported as the default export. 13 | * 14 | * Example: 15 | * 16 | * ```js 17 | * const config = {}; 18 | * export default config; 19 | * ``` 20 | * 21 | * @param magicast the module 22 | * 23 | * @returns an object containing the proxified config object and the 24 | * declaration "parent" to attach the modified config to later. 25 | * If no config declaration is found, undefined is returned. 26 | */ 27 | export function getConfigFromVariableDeclaration( 28 | magicast: ProxifiedModule, 29 | ): { 30 | declaration: VariableDeclarator; 31 | config: ProxifiedObject | undefined; 32 | } { 33 | if (magicast.exports.default.$type !== "identifier") { 34 | throw new MagicastError( 35 | `Not supported: Cannot modify this kind of default export (${magicast.exports.default.$type})`, 36 | ); 37 | } 38 | 39 | const configDecalarationId = magicast.exports.default.$name; 40 | 41 | for (const node of (magicast.$ast as Program).body) { 42 | if (node.type === "VariableDeclaration") { 43 | for (const declaration of node.declarations) { 44 | if ( 45 | declaration.id.type === "Identifier" && 46 | declaration.id.name === configDecalarationId && 47 | declaration.init 48 | ) { 49 | const init = 50 | declaration.init.type === "TSSatisfiesExpression" 51 | ? declaration.init.expression 52 | : declaration.init; 53 | 54 | const code = generateCode(init).code; 55 | const configExpression = parseExpression(code); 56 | 57 | return { 58 | declaration, 59 | config: configFromNode(configExpression), 60 | }; 61 | } 62 | } 63 | } 64 | } 65 | throw new MagicastError("Couldn't find config declaration"); 66 | } 67 | 68 | function configFromNode(node: Proxified): ProxifiedObject { 69 | if (node.$type === "function-call") { 70 | return node.$args[0]; 71 | } 72 | return node; 73 | } 74 | -------------------------------------------------------------------------------- /src/helpers/deep-merge.ts: -------------------------------------------------------------------------------- 1 | import type { Proxified } from "../types"; 2 | 3 | export function deepMergeObject(magicast: Proxified, object: any) { 4 | if (typeof object === "object") { 5 | for (const key in object) { 6 | if ( 7 | typeof magicast[key] === "object" && 8 | typeof object[key] === "object" 9 | ) { 10 | deepMergeObject(magicast[key], object[key]); 11 | } else { 12 | magicast[key] = object[key]; 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./deep-merge"; 2 | export * from "./nuxt"; 3 | export * from "./vite"; 4 | export * from "./config"; 5 | -------------------------------------------------------------------------------- /src/helpers/nuxt.ts: -------------------------------------------------------------------------------- 1 | import type { ProxifiedModule } from "../proxy/types"; 2 | import { getDefaultExportOptions } from "./config"; 3 | import { deepMergeObject } from "./deep-merge"; 4 | 5 | export function addNuxtModule( 6 | magicast: ProxifiedModule, 7 | name: string, 8 | optionsKey?: string, 9 | options?: any, 10 | ) { 11 | const config = getDefaultExportOptions(magicast); 12 | 13 | config.modules ||= []; 14 | if (!config.modules.includes(name)) { 15 | config.modules.push(name); 16 | } 17 | 18 | if (optionsKey) { 19 | config[optionsKey] ||= {}; 20 | deepMergeObject(config[optionsKey], options); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/vite.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Proxified, 3 | ProxifiedFunctionCall, 4 | ProxifiedModule, 5 | } from "../proxy/types"; 6 | import { builders } from "../builders"; 7 | import { generateCode } from "../code"; 8 | import { 9 | getConfigFromVariableDeclaration, 10 | getDefaultExportOptions, 11 | } from "./config"; 12 | import { deepMergeObject } from "./deep-merge"; 13 | 14 | export interface AddVitePluginOptions { 15 | /** 16 | * The import path of the plugin 17 | */ 18 | from: string; 19 | /** 20 | * The import name of the plugin 21 | * @default "default" 22 | */ 23 | imported?: string; 24 | /** 25 | * The name of local variable 26 | */ 27 | constructor: string; 28 | /** 29 | * The options of the plugin 30 | */ 31 | options?: Record; 32 | 33 | /** 34 | * The index in the plugins array where the plugin should be inserted at. 35 | * By default, the plugin is appended to the array. 36 | */ 37 | index?: number; 38 | } 39 | 40 | export interface UpdateVitePluginConfigOptions { 41 | /** 42 | * The import path of the plugin 43 | */ 44 | from: string; 45 | /** 46 | * The import name of the plugin 47 | * @default "default" 48 | */ 49 | imported?: string; 50 | } 51 | 52 | export function addVitePlugin( 53 | magicast: ProxifiedModule, 54 | plugin: AddVitePluginOptions, 55 | ) { 56 | const config: Proxified | undefined = getDefaultExportOptions(magicast); 57 | 58 | if (config.$type === "identifier") { 59 | insertPluginIntoVariableDeclarationConfig(magicast, plugin); 60 | } else { 61 | insertPluginIntoConfig(plugin, config); 62 | } 63 | 64 | magicast.imports.$prepend({ 65 | from: plugin.from, 66 | local: plugin.constructor, 67 | imported: plugin.imported || "default", 68 | }); 69 | 70 | return true; 71 | } 72 | 73 | export function findVitePluginCall( 74 | magicast: ProxifiedModule, 75 | plugin: UpdateVitePluginConfigOptions | string, 76 | ): ProxifiedFunctionCall | undefined { 77 | const _plugin = 78 | typeof plugin === "string" ? { from: plugin, imported: "default" } : plugin; 79 | 80 | const config = getDefaultExportOptions(magicast); 81 | 82 | const constructor = magicast.imports.$items.find( 83 | (i) => 84 | i.from === _plugin.from && i.imported === (_plugin.imported || "default"), 85 | )?.local; 86 | 87 | return config.plugins?.find( 88 | (p: any) => p && p.$type === "function-call" && p.$callee === constructor, 89 | ); 90 | } 91 | 92 | export function updateVitePluginConfig( 93 | magicast: ProxifiedModule, 94 | plugin: UpdateVitePluginConfigOptions | string, 95 | handler: Record | ((args: any[]) => any[]), 96 | ) { 97 | const item = findVitePluginCall(magicast, plugin); 98 | if (!item) { 99 | return false; 100 | } 101 | 102 | if (typeof handler === "function") { 103 | item.$args = handler(item.$args); 104 | } else if (item.$args[0]) { 105 | deepMergeObject(item.$args[0], handler); 106 | } else { 107 | item.$args[0] = handler; 108 | } 109 | 110 | return true; 111 | } 112 | 113 | /** 114 | * Insert @param plugin into a config object that's declared as a variable in 115 | * the module (@param magicast). 116 | */ 117 | function insertPluginIntoVariableDeclarationConfig( 118 | magicast: ProxifiedModule, 119 | plugin: AddVitePluginOptions, 120 | ) { 121 | const { config: configObject, declaration } = 122 | getConfigFromVariableDeclaration(magicast); 123 | 124 | insertPluginIntoConfig(plugin, configObject); 125 | 126 | if (declaration.init) { 127 | if (declaration.init.type === "ObjectExpression") { 128 | // @ts-ignore this works despite the type error because of recast 129 | declaration.init = generateCode(configObject).code; 130 | } else if ( 131 | declaration.init.type === "CallExpression" && 132 | declaration.init.callee.type === "Identifier" 133 | ) { 134 | // @ts-ignore this works despite the type error because of recast 135 | declaration.init = generateCode( 136 | builders.functionCall(declaration.init.callee.name, configObject), 137 | ).code; 138 | } else if (declaration.init.type === "TSSatisfiesExpression") { 139 | if (declaration.init.expression.type === "ObjectExpression") { 140 | // @ts-ignore this works despite the type error because of recast 141 | declaration.init.expression = generateCode(configObject).code; 142 | } 143 | if ( 144 | declaration.init.expression.type === "CallExpression" && 145 | declaration.init.expression.callee.type === "Identifier" 146 | ) { 147 | // @ts-ignore this works despite the type error because of recast 148 | declaration.init.expression = generateCode( 149 | builders.functionCall( 150 | declaration.init.expression.callee.name, 151 | configObject, 152 | ), 153 | ).code; 154 | } 155 | } 156 | } 157 | } 158 | 159 | function insertPluginIntoConfig(plugin: AddVitePluginOptions, config: any) { 160 | const insertionIndex = plugin.index ?? config.plugins?.length ?? 0; 161 | 162 | config.plugins ||= []; 163 | 164 | config.plugins.splice( 165 | insertionIndex, 166 | 0, 167 | plugin.options 168 | ? builders.functionCall(plugin.constructor, plugin.options) 169 | : builders.functionCall(plugin.constructor), 170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./code"; 2 | export * from "./types"; 3 | export * from "./format"; 4 | export * from "./error"; 5 | export * from "./builders"; 6 | -------------------------------------------------------------------------------- /src/proxy/_utils.ts: -------------------------------------------------------------------------------- 1 | import * as recast from "recast"; 2 | import type { ASTNode } from "../types"; 3 | import { MagicastError } from "../error"; 4 | 5 | export const LITERALS_AST = new Set([ 6 | "Literal", 7 | "StringLiteral", 8 | "NumericLiteral", 9 | "BooleanLiteral", 10 | "NullLiteral", 11 | "RegExpLiteral", 12 | "BigIntLiteral", 13 | ]); 14 | 15 | export const LITERALS_TYPEOF = new Set([ 16 | "string", 17 | "number", 18 | "boolean", 19 | "bigint", 20 | "symbol", 21 | "undefined", 22 | ]); 23 | 24 | const b = recast.types.builders; 25 | 26 | export function isValidPropName(name: string) { 27 | return /^[$A-Z_a-z][\w$]*$/.test(name); 28 | } 29 | 30 | const PROXY_KEY = "__magicast_proxy"; 31 | 32 | export function literalToAst(value: any, seen = new Set()): ASTNode { 33 | if (value === undefined) { 34 | return b.identifier("undefined") as any; 35 | } 36 | if (value === null) { 37 | // eslint-disable-next-line unicorn/no-null 38 | return b.literal(null) as any; 39 | } 40 | if (LITERALS_TYPEOF.has(typeof value)) { 41 | return b.literal(value) as any; 42 | } 43 | if (seen.has(value)) { 44 | throw new MagicastError("Can not serialize circular reference"); 45 | } 46 | seen.add(value); 47 | 48 | // forward proxy 49 | if (value[PROXY_KEY]) { 50 | return value.$ast; 51 | } 52 | 53 | if (value instanceof RegExp) { 54 | const regex = b.regExpLiteral(value.source, value.flags) as any; 55 | // seems to be a bug in recast 56 | delete regex.extra.raw; 57 | return regex; 58 | } 59 | if (value instanceof Set) { 60 | return b.newExpression(b.identifier("Set"), [ 61 | b.arrayExpression([...value].map((n) => literalToAst(n, seen)) as any), 62 | ]) as any; 63 | } 64 | if (value instanceof Date) { 65 | return b.newExpression(b.identifier("Date"), [ 66 | b.literal(value.toISOString()), 67 | ]) as any; 68 | } 69 | if (value instanceof Map) { 70 | return b.newExpression(b.identifier("Map"), [ 71 | b.arrayExpression( 72 | [...value].map(([key, value]) => { 73 | return b.arrayExpression([ 74 | literalToAst(key, seen) as any, 75 | literalToAst(value, seen) as any, 76 | ]) as any; 77 | }) as any, 78 | ), 79 | ]) as any; 80 | } 81 | if (Array.isArray(value)) { 82 | return b.arrayExpression( 83 | value.map((n) => literalToAst(n, seen)) as any, 84 | ) as any; 85 | } 86 | if (typeof value === "object") { 87 | return b.objectExpression( 88 | Object.entries(value).map(([key, value]) => { 89 | return b.property( 90 | "init", 91 | /^[$A-Z_a-z][\w$]*$/g.test(key) ? b.identifier(key) : b.literal(key), 92 | literalToAst(value, seen) as any, 93 | ) as any; 94 | }), 95 | ) as any; 96 | } 97 | return b.literal(value) as any; 98 | } 99 | 100 | export function makeProxyUtils( 101 | node: ASTNode, 102 | extend: T = {} as T, 103 | ): Record { 104 | const obj = extend as any; 105 | obj[PROXY_KEY] = true; 106 | obj.$ast = node; 107 | obj.$type ||= "object"; 108 | return obj; 109 | } 110 | 111 | const propertyDescriptor = { 112 | enumerable: true, 113 | configurable: true, 114 | }; 115 | 116 | export function createProxy( 117 | node: ASTNode, 118 | extend: any, 119 | handler: ProxyHandler, 120 | ): T { 121 | const utils = makeProxyUtils(node, extend); 122 | return new Proxy( 123 | {}, 124 | { 125 | ownKeys() { 126 | return Object.keys(utils).filter( 127 | (i) => i !== PROXY_KEY && !i.startsWith("$"), 128 | ); 129 | }, 130 | getOwnPropertyDescriptor() { 131 | return propertyDescriptor; 132 | }, 133 | has(_target: any, key: string | symbol) { 134 | if (key in utils) { 135 | return true; 136 | } 137 | return false; 138 | }, 139 | ...handler, 140 | get(target: any, key: string | symbol, receiver: any) { 141 | if (key in utils) { 142 | return (utils as any)[key]; 143 | } 144 | if (handler.get) { 145 | return handler.get(target, key, receiver); 146 | } 147 | }, 148 | set(target: any, key: string | symbol, value: any, receiver: any) { 149 | if (key in utils) { 150 | (utils as any)[key] = value; 151 | return true; 152 | } 153 | if (handler.set) { 154 | return handler.set(target, key, value, receiver); 155 | } 156 | return false; 157 | }, 158 | }, 159 | ) as T; 160 | } 161 | -------------------------------------------------------------------------------- /src/proxy/array.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode } from "../types"; 2 | import type { ProxifiedArray, ProxifiedModule } from "./types"; 3 | import { literalToAst, createProxy } from "./_utils"; 4 | import { proxify } from "./proxify"; 5 | 6 | export function proxifyArrayElements( 7 | node: ASTNode, 8 | elements: ASTNode[], 9 | mod?: ProxifiedModule, 10 | ): ProxifiedArray { 11 | const getItem = (key: number) => { 12 | return elements[key]; 13 | }; 14 | 15 | const replaceItem = (key: number, value: ASTNode) => { 16 | elements[key] = value as any; 17 | }; 18 | 19 | return createProxy( 20 | node, 21 | { 22 | $type: "array", 23 | push(value: any) { 24 | elements.push(literalToAst(value) as any); 25 | }, 26 | pop() { 27 | return proxify(elements.pop() as any, mod); 28 | }, 29 | unshift(value: any) { 30 | elements.unshift(literalToAst(value) as any); 31 | }, 32 | shift() { 33 | return proxify(elements.shift() as any, mod); 34 | }, 35 | splice(start: number, deleteCount: number, ...items: any[]) { 36 | const deleted = elements.splice( 37 | start, 38 | deleteCount, 39 | ...items.map((n) => literalToAst(n)), 40 | ); 41 | return deleted.map((n) => proxify(n as any, mod)); 42 | }, 43 | find(predicate: (value: any, index: number, arr: any[]) => boolean) { 44 | // eslint-disable-next-line unicorn/no-array-callback-reference 45 | return elements.map((n) => proxify(n as any, mod)).find(predicate); 46 | }, 47 | findIndex(predicate: (value: any, index: number, arr: any[]) => boolean) { 48 | // eslint-disable-next-line unicorn/no-array-callback-reference 49 | return elements.map((n) => proxify(n as any, mod)).findIndex(predicate); 50 | }, 51 | includes(value: any) { 52 | return elements.map((n) => proxify(n as any, mod)).includes(value); 53 | }, 54 | toJSON() { 55 | return elements.map((n) => proxify(n as any, mod)); 56 | }, 57 | }, 58 | { 59 | get(_, key) { 60 | if (key === "length") { 61 | return elements.length; 62 | } 63 | if (key === Symbol.iterator) { 64 | return function* () { 65 | for (const item of elements) { 66 | yield proxify(item as any, mod); 67 | } 68 | }; 69 | } 70 | if (typeof key === "symbol") { 71 | return; 72 | } 73 | const index = +key; 74 | if (Number.isNaN(index)) { 75 | return; 76 | } 77 | const prop = getItem(index); 78 | if (prop) { 79 | return proxify(prop, mod); 80 | } 81 | }, 82 | set(_, key, value) { 83 | if (typeof key === "symbol") { 84 | return false; 85 | } 86 | const index = +key; 87 | if (Number.isNaN(index)) { 88 | return false; 89 | } 90 | replaceItem(index, literalToAst(value)); 91 | return true; 92 | }, 93 | deleteProperty(_, key) { 94 | if (typeof key === "symbol") { 95 | return false; 96 | } 97 | const index = +key; 98 | if (Number.isNaN(index)) { 99 | return false; 100 | } 101 | elements[index] = literalToAst(undefined); 102 | return true; 103 | }, 104 | ownKeys() { 105 | return ["length", ...elements.map((_, i) => i.toString())]; 106 | }, 107 | }, 108 | ); 109 | } 110 | 111 | export function proxifyArray( 112 | node: ASTNode, 113 | mod?: ProxifiedModule, 114 | ): ProxifiedArray { 115 | if (!("elements" in node)) { 116 | return undefined as any; 117 | } 118 | return proxifyArrayElements(node, node.elements as any, mod) as any; 119 | } 120 | -------------------------------------------------------------------------------- /src/proxy/arrow-function-expression.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ASTNode, 3 | ProxifiedArrowFunctionExpression, 4 | ProxifiedModule, 5 | } from "../types"; 6 | import { MagicastError } from "../error"; 7 | import { createProxy } from "./_utils"; 8 | import { proxifyArrayElements } from "./array"; 9 | import { proxify } from "./proxify"; 10 | 11 | export function proxifyArrowFunctionExpression( 12 | node: ASTNode, 13 | mod?: ProxifiedModule, 14 | ): ProxifiedArrowFunctionExpression { 15 | if (node.type !== "ArrowFunctionExpression") { 16 | throw new MagicastError("Not an arrow function expression"); 17 | } 18 | 19 | const parametersProxy = proxifyArrayElements(node, node.params, mod); 20 | 21 | return createProxy( 22 | node, 23 | { 24 | $type: "arrow-function-expression", 25 | $params: parametersProxy, 26 | $body: proxify(node.body, mod), 27 | }, 28 | {}, 29 | ) as ProxifiedArrowFunctionExpression; 30 | } 31 | -------------------------------------------------------------------------------- /src/proxy/exports.ts: -------------------------------------------------------------------------------- 1 | import * as recast from "recast"; 2 | import type { Program } from "@babel/types"; 3 | import type { ProxifiedModule } from "./types"; 4 | import { createProxy, literalToAst } from "./_utils"; 5 | import { proxify } from "./proxify"; 6 | 7 | const b = recast.types.builders; 8 | 9 | export function createExportsProxy(root: Program, mod: ProxifiedModule) { 10 | const findExport = (key: string) => { 11 | const type = 12 | key === "default" ? "ExportDefaultDeclaration" : "ExportNamedDeclaration"; 13 | 14 | for (const n of root.body) { 15 | if (n.type === type) { 16 | if (key === "default") { 17 | return n.declaration; 18 | } 19 | if (n.declaration && "declarations" in n.declaration) { 20 | const dec = n.declaration.declarations[0]; 21 | if ("name" in dec.id && dec.id.name === key) { 22 | return dec.init as any; 23 | } 24 | } 25 | } 26 | } 27 | }; 28 | 29 | const updateOrAddExport = (key: string, value: any) => { 30 | const type = 31 | key === "default" ? "ExportDefaultDeclaration" : "ExportNamedDeclaration"; 32 | 33 | const node = literalToAst(value) as any; 34 | for (const n of root.body) { 35 | if (n.type === type) { 36 | if (key === "default") { 37 | n.declaration = node; 38 | return; 39 | } 40 | if (n.declaration && "declarations" in n.declaration) { 41 | const dec = n.declaration.declarations[0]; 42 | if ("name" in dec.id && dec.id.name === key) { 43 | dec.init = node; 44 | return; 45 | } 46 | } 47 | } 48 | } 49 | 50 | root.body.push( 51 | key === "default" 52 | ? b.exportDefaultDeclaration(node) 53 | : (b.exportNamedDeclaration( 54 | b.variableDeclaration("const", [ 55 | b.variableDeclarator(b.identifier(key), node), 56 | ]), 57 | ) as any), 58 | ); 59 | }; 60 | 61 | return createProxy( 62 | root, 63 | { 64 | $type: "exports", 65 | }, 66 | { 67 | get(_, prop) { 68 | const node = findExport(prop as string); 69 | if (node) { 70 | return proxify(node, mod); 71 | } 72 | }, 73 | set(_, prop, value) { 74 | updateOrAddExport(prop as string, value); 75 | return true; 76 | }, 77 | ownKeys() { 78 | return root.body 79 | .flatMap((i) => { 80 | if (i.type === "ExportDefaultDeclaration") { 81 | return ["default"]; 82 | } 83 | if ( 84 | i.type === "ExportNamedDeclaration" && 85 | i.declaration && 86 | "declarations" in i.declaration 87 | ) { 88 | return i.declaration.declarations.map((d) => 89 | "name" in d.id ? d.id.name : "", 90 | ); 91 | } 92 | return []; 93 | }) 94 | .filter(Boolean); 95 | }, 96 | deleteProperty(_, prop) { 97 | const type = 98 | prop === "default" 99 | ? "ExportDefaultDeclaration" 100 | : "ExportNamedDeclaration"; 101 | 102 | for (let i = 0; i < root.body.length; i++) { 103 | const n = root.body[i]; 104 | if (n.type === type) { 105 | if (prop === "default") { 106 | root.body.splice(i, 1); 107 | return true; 108 | } 109 | if (n.declaration && "declarations" in n.declaration) { 110 | const dec = n.declaration.declarations[0]; 111 | if ("name" in dec.id && dec.id.name === prop) { 112 | root.body.splice(i, 1); 113 | return true; 114 | } 115 | } 116 | } 117 | } 118 | return false; 119 | }, 120 | }, 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/proxy/function-call.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import type { ProxifiedFunctionCall, ProxifiedModule } from "./types"; 4 | import { createProxy } from "./_utils"; 5 | import { proxifyArrayElements } from "./array"; 6 | 7 | export function proxifyFunctionCall( 8 | node: ASTNode, 9 | mod?: ProxifiedModule, 10 | ): ProxifiedFunctionCall { 11 | if (node.type !== "CallExpression") { 12 | throw new MagicastError("Not a function call"); 13 | } 14 | 15 | function stringifyExpression(node: ASTNode): string { 16 | if (node.type === "Identifier") { 17 | return node.name; 18 | } 19 | if (node.type === "MemberExpression") { 20 | return `${stringifyExpression(node.object)}.${stringifyExpression( 21 | node.property, 22 | )}`; 23 | } 24 | throw new MagicastError("Not implemented"); 25 | } 26 | 27 | const argumentsProxy = proxifyArrayElements(node, node.arguments, mod); 28 | 29 | return createProxy( 30 | node, 31 | { 32 | $type: "function-call", 33 | $callee: stringifyExpression(node.callee as any), 34 | $args: argumentsProxy, 35 | }, 36 | {}, 37 | ) as ProxifiedFunctionCall; 38 | } 39 | -------------------------------------------------------------------------------- /src/proxy/identifier.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode, ProxifiedIdentifier } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import { createProxy } from "./_utils"; 4 | 5 | export function proxifyIdentifier(node: ASTNode): ProxifiedIdentifier { 6 | if (node.type !== "Identifier") { 7 | throw new MagicastError("Not an identifier"); 8 | } 9 | 10 | return createProxy( 11 | node, 12 | { 13 | $type: "identifier", 14 | $name: node.name, 15 | }, 16 | {}, 17 | ) as ProxifiedIdentifier; 18 | } 19 | -------------------------------------------------------------------------------- /src/proxy/imports.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-nested-ternary */ 2 | import * as recast from "recast"; 3 | import type { 4 | ImportDeclaration, 5 | ImportDefaultSpecifier, 6 | ImportNamespaceSpecifier, 7 | ImportSpecifier, 8 | Program, 9 | } from "@babel/types"; 10 | import { MagicastError } from "../error"; 11 | import type { 12 | ImportItemInput, 13 | ProxifiedImportItem, 14 | ProxifiedImportsMap, 15 | ProxifiedModule, 16 | } from "./types"; 17 | import { createProxy } from "./_utils"; 18 | 19 | const b = recast.types.builders; 20 | const _importProxyCache = new WeakMap(); 21 | 22 | export function creatImportProxy( 23 | node: ImportDeclaration, 24 | specifier: 25 | | ImportSpecifier 26 | | ImportNamespaceSpecifier 27 | | ImportDefaultSpecifier, 28 | root: Program, 29 | ): ProxifiedImportItem { 30 | if (_importProxyCache.has(specifier)) { 31 | return _importProxyCache.get(specifier)!; 32 | } 33 | const proxy = createProxy( 34 | specifier, 35 | { 36 | get $declaration() { 37 | return node; 38 | }, 39 | get imported() { 40 | if (specifier.type === "ImportDefaultSpecifier") { 41 | return "default"; 42 | } 43 | if (specifier.type === "ImportNamespaceSpecifier") { 44 | return "*"; 45 | } 46 | if (specifier.imported.type === "Identifier") { 47 | return specifier.imported.name; 48 | } 49 | return specifier.imported.value; 50 | }, 51 | set imported(value) { 52 | if (specifier.type !== "ImportSpecifier") { 53 | throw new MagicastError( 54 | "Changing import name is not yet implemented", 55 | ); 56 | } 57 | if (specifier.imported.type === "Identifier") { 58 | specifier.imported.name = value; 59 | } else { 60 | specifier.imported.value = value; 61 | } 62 | }, 63 | get local() { 64 | return specifier.local.name; 65 | }, 66 | set local(value) { 67 | specifier.local.name = value; 68 | }, 69 | get from() { 70 | return node.source.value; 71 | }, 72 | set from(value) { 73 | if (value === node.source.value) { 74 | return; 75 | } 76 | 77 | node.specifiers = node.specifiers.filter((s) => s !== specifier); 78 | if (node.specifiers.length === 0) { 79 | root.body = root.body.filter((s) => s !== node); 80 | } 81 | 82 | const declaration = root.body.find( 83 | (i) => i.type === "ImportDeclaration" && i.source.value === value, 84 | ) as ImportDeclaration | undefined; 85 | if (declaration) { 86 | // TODO: insert after the last import maybe? 87 | declaration.specifiers.push(specifier as any); 88 | } else { 89 | root.body.unshift( 90 | b.importDeclaration( 91 | [specifier as any], 92 | b.stringLiteral(value), 93 | ) as any, 94 | ); 95 | } 96 | }, 97 | toJSON() { 98 | return { 99 | imported: this.imported, 100 | local: this.local, 101 | from: this.from, 102 | }; 103 | }, 104 | }, 105 | { 106 | ownKeys() { 107 | return ["imported", "local", "from", "toJSON"]; 108 | }, 109 | }, 110 | ) as ProxifiedImportItem; 111 | _importProxyCache.set(specifier, proxy); 112 | return proxy; 113 | } 114 | 115 | export function createImportsProxy( 116 | root: Program, 117 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 118 | mod: ProxifiedModule, 119 | ): ProxifiedImportsMap { 120 | // TODO: cache 121 | const getAllImports = () => { 122 | const imports: ReturnType[] = []; 123 | for (const n of root.body) { 124 | if (n.type === "ImportDeclaration") { 125 | for (const specifier of n.specifiers) { 126 | imports.push(creatImportProxy(n, specifier, root)); 127 | } 128 | } 129 | } 130 | return imports; 131 | }; 132 | 133 | const updateImport = ( 134 | key: string, 135 | value: ImportItemInput, 136 | order: "prepend" | "append", 137 | ) => { 138 | const imports = getAllImports(); 139 | const item = imports.find((i) => i.local === key); 140 | const local = value.local || key; 141 | if (item) { 142 | item.imported = value.imported; 143 | item.local = local; 144 | item.from = value.from; 145 | return true; 146 | } 147 | 148 | const specifier = 149 | value.imported === "default" 150 | ? b.importDefaultSpecifier(b.identifier(local)) 151 | : value.imported === "*" 152 | ? b.importNamespaceSpecifier(b.identifier(local)) 153 | : b.importSpecifier( 154 | b.identifier(value.imported), 155 | b.identifier(local), 156 | ); 157 | 158 | const declaration = imports.find( 159 | (i) => i.from === value.from, 160 | )?.$declaration; 161 | if (declaration) { 162 | declaration.specifiers.push(specifier as any); 163 | } else if (order === "prepend" || imports.length === 0) { 164 | root.body.unshift( 165 | b.importDeclaration([specifier], b.stringLiteral(value.from)) as any, 166 | ); 167 | } else { 168 | // The `imports` length is already checked above, so `at(-1)` will exist 169 | const lastImport = imports.at(-1)!.$declaration; 170 | const lastImportIndex = root.body.indexOf(lastImport); 171 | root.body.splice( 172 | lastImportIndex + 1, 173 | 0, 174 | b.importDeclaration([specifier], b.stringLiteral(value.from)) as any, 175 | ); 176 | } 177 | return true; 178 | }; 179 | 180 | const removeImport = (key: string) => { 181 | const item = getAllImports().find((i) => i.local === key); 182 | if (!item) { 183 | return false; 184 | } 185 | const node = item.$declaration; 186 | const specifier = item.$ast; 187 | node.specifiers = node.specifiers.filter((s) => s !== specifier); 188 | if (node.specifiers.length === 0) { 189 | root.body = root.body.filter((n) => n !== node); 190 | } 191 | return true; 192 | }; 193 | 194 | const proxy = createProxy( 195 | root, 196 | { 197 | $type: "imports", 198 | $add(item: ImportItemInput) { 199 | updateImport(item.local || item.imported, item, "prepend"); 200 | }, 201 | $prepend(item: ImportItemInput) { 202 | updateImport(item.local || item.imported, item, "prepend"); 203 | }, 204 | $append(item: ImportItemInput) { 205 | updateImport(item.local || item.imported, item, "append"); 206 | }, 207 | get $items() { 208 | return getAllImports(); 209 | }, 210 | toJSON() { 211 | // eslint-disable-next-line unicorn/no-array-reduce 212 | return getAllImports().reduce((acc, i) => { 213 | acc[i.local] = i; 214 | return acc; 215 | }, {} as any); 216 | }, 217 | }, 218 | { 219 | get(_, prop) { 220 | return getAllImports().find((i) => i.local === prop); 221 | }, 222 | set(_, prop, value) { 223 | return updateImport(prop as string, value, "prepend"); 224 | }, 225 | deleteProperty(_, prop) { 226 | return removeImport(prop as string); 227 | }, 228 | ownKeys() { 229 | return getAllImports().map((i) => i.local); 230 | }, 231 | has(_, prop) { 232 | return getAllImports().some((i) => i.local === prop); 233 | }, 234 | }, 235 | ) as any as ProxifiedImportsMap; 236 | 237 | return proxy; 238 | } 239 | -------------------------------------------------------------------------------- /src/proxy/logical-expression.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode, ProxifiedLogicalExpression } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import { createProxy } from "./_utils"; 4 | 5 | export function proxifyLogicalExpression( 6 | node: ASTNode, 7 | ): ProxifiedLogicalExpression { 8 | if (node.type !== "LogicalExpression") { 9 | throw new MagicastError("Not a logical expression"); 10 | } 11 | 12 | return createProxy( 13 | node, 14 | { 15 | $type: "logicalExpression", 16 | }, 17 | {}, 18 | ) as ProxifiedLogicalExpression; 19 | } 20 | -------------------------------------------------------------------------------- /src/proxy/member-expression.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode, ProxifiedMemberExpression } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import { createProxy } from "./_utils"; 4 | 5 | export function proxifyMemberExpression( 6 | node: ASTNode, 7 | ): ProxifiedMemberExpression { 8 | if (node.type !== "MemberExpression") { 9 | throw new MagicastError("Not a member expression"); 10 | } 11 | 12 | return createProxy( 13 | node, 14 | { 15 | $type: "memberExpression", 16 | }, 17 | {}, 18 | ) as ProxifiedMemberExpression; 19 | } 20 | -------------------------------------------------------------------------------- /src/proxy/module.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedFileNode } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import { generateCode } from "../code"; 4 | import type { ProxifiedModule } from "./types"; 5 | import { createImportsProxy } from "./imports"; 6 | import { createExportsProxy } from "./exports"; 7 | import { createProxy } from "./_utils"; 8 | 9 | export function proxifyModule( 10 | ast: ParsedFileNode, 11 | code: string, 12 | ): ProxifiedModule { 13 | const root = ast.program; 14 | if (root.type !== "Program") { 15 | throw new MagicastError(`Cannot proxify ${ast.type} as module`); 16 | } 17 | 18 | const util = { 19 | $code: code, 20 | $type: "module", 21 | } as ProxifiedModule; 22 | 23 | const mod = createProxy(root, util, { 24 | ownKeys() { 25 | return ["imports", "exports", "generate"]; 26 | }, 27 | }) as ProxifiedModule; 28 | 29 | util.exports = createExportsProxy(root, mod) as any; 30 | util.imports = createImportsProxy(root, mod) as any; 31 | util.generate = (options) => generateCode(mod, options); 32 | 33 | return mod; 34 | } 35 | -------------------------------------------------------------------------------- /src/proxy/new-expression.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import type { ProxifiedModule, ProxifiedNewExpression } from "./types"; 4 | import { createProxy } from "./_utils"; 5 | import { proxifyArrayElements } from "./array"; 6 | 7 | export function proxifyNewExpression( 8 | node: ASTNode, 9 | mod?: ProxifiedModule, 10 | ): ProxifiedNewExpression { 11 | if (node.type !== "NewExpression") { 12 | throw new MagicastError("Not a new expression"); 13 | } 14 | 15 | function stringifyExpression(node: ASTNode): string { 16 | if (node.type === "Identifier") { 17 | return node.name; 18 | } 19 | if (node.type === "MemberExpression") { 20 | return `${stringifyExpression(node.object)}.${stringifyExpression( 21 | node.property, 22 | )}`; 23 | } 24 | throw new MagicastError("Not implemented"); 25 | } 26 | 27 | const argumentsProxy = proxifyArrayElements(node, node.arguments, mod); 28 | 29 | return createProxy( 30 | node, 31 | { 32 | $type: "new-expression", 33 | $callee: stringifyExpression(node.callee as any), 34 | $args: argumentsProxy, 35 | }, 36 | {}, 37 | ) as ProxifiedNewExpression; 38 | } 39 | -------------------------------------------------------------------------------- /src/proxy/object.ts: -------------------------------------------------------------------------------- 1 | import * as recast from "recast"; 2 | import type { ASTNode } from "../types"; 3 | import { MagicastError } from "../error"; 4 | import type { ProxifiedModule, ProxifiedObject } from "./types"; 5 | import { literalToAst, createProxy, isValidPropName } from "./_utils"; 6 | import { proxify } from "./proxify"; 7 | 8 | const b = recast.types.builders; 9 | 10 | export function proxifyObject( 11 | node: ASTNode, 12 | mod?: ProxifiedModule, 13 | ): ProxifiedObject { 14 | if (!("properties" in node)) { 15 | return undefined as any; 16 | } 17 | 18 | const getProp = (key: string | symbol) => { 19 | for (const prop of node.properties) { 20 | if ("key" in prop && "name" in prop.key && prop.key.name === key) { 21 | // TODO: 22 | return (prop as any).value; 23 | } 24 | if ( 25 | prop.type === "ObjectProperty" && 26 | (prop.key.type === "StringLiteral" || 27 | prop.key.type === "NumericLiteral" || 28 | prop.key.type === "BooleanLiteral") && 29 | prop.key.value.toString() === key 30 | ) { 31 | return (prop.value as any).value ?? prop.value; 32 | } 33 | } 34 | }; 35 | 36 | const getPropName = ( 37 | prop: (typeof node.properties)[0], 38 | throwError = false, 39 | ) => { 40 | if ("key" in prop && "name" in prop.key) { 41 | return prop.key.name; 42 | } 43 | if ( 44 | prop.type === "ObjectProperty" && 45 | (prop.key.type === "StringLiteral" || 46 | prop.key.type === "NumericLiteral" || 47 | prop.key.type === "BooleanLiteral") 48 | ) { 49 | return prop.key.value.toString(); 50 | } 51 | if (throwError) { 52 | throw new MagicastError(`Casting "${prop.type}" is not supported`, { 53 | ast: prop, 54 | code: mod?.$code, 55 | }); 56 | } 57 | }; 58 | 59 | const replaceOrAddProp = (key: string, value: ASTNode) => { 60 | const prop = (node.properties as any[]).find( 61 | (prop: any) => getPropName(prop) === key, 62 | ); 63 | if (prop) { 64 | prop.value = value; 65 | } else if (isValidPropName(key)) { 66 | node.properties.push({ 67 | type: "Property", 68 | key: { 69 | type: "Identifier", 70 | name: key, 71 | }, 72 | value, 73 | } as any); 74 | } else { 75 | node.properties.push({ 76 | type: "ObjectProperty", 77 | key: b.stringLiteral(key), 78 | value, 79 | } as any); 80 | } 81 | }; 82 | 83 | return createProxy( 84 | node, 85 | { 86 | $type: "object", 87 | toJSON() { 88 | // @ts-expect-error 89 | // eslint-disable-next-line unicorn/no-array-reduce 90 | return node.properties.reduce((acc, prop) => { 91 | if ("key" in prop && "name" in prop.key) { 92 | acc[prop.key.name] = proxify(prop.value, mod); 93 | } 94 | return acc; 95 | }, {} as any); 96 | }, 97 | }, 98 | { 99 | get(_, key) { 100 | const prop = getProp(key); 101 | if (prop) { 102 | return proxify(prop, mod); 103 | } 104 | }, 105 | set(_, key, value) { 106 | if (typeof key !== "string") { 107 | key = String(key); 108 | } 109 | replaceOrAddProp(key, literalToAst(value)); 110 | return true; 111 | }, 112 | deleteProperty(_, key) { 113 | if (typeof key !== "string") { 114 | key = String(key); 115 | } 116 | const index = node.properties.findIndex( 117 | (prop) => 118 | "key" in prop && "name" in prop.key && prop.key.name === key, 119 | ); 120 | if (index !== -1) { 121 | node.properties.splice(index, 1); 122 | } 123 | return true; 124 | }, 125 | ownKeys() { 126 | return node.properties 127 | .map((prop) => getPropName(prop, true)) 128 | .filter(Boolean) as string[]; 129 | }, 130 | }, 131 | ) as ProxifiedObject; 132 | } 133 | -------------------------------------------------------------------------------- /src/proxy/proxify.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode } from "../types"; 2 | import { MagicastError } from "../error"; 3 | import type { Proxified, ProxifiedModule, ProxifiedValue } from "./types"; 4 | import { proxifyArray } from "./array"; 5 | import { proxifyFunctionCall } from "./function-call"; 6 | import { proxifyArrowFunctionExpression } from "./arrow-function-expression"; 7 | import { proxifyObject } from "./object"; 8 | import { proxifyNewExpression } from "./new-expression"; 9 | import { proxifyIdentifier } from "./identifier"; 10 | import { proxifyLogicalExpression } from "./logical-expression"; 11 | import { proxifyMemberExpression } from "./member-expression"; 12 | import { LITERALS_AST, LITERALS_TYPEOF } from "./_utils"; 13 | 14 | const _cache = new WeakMap(); 15 | 16 | export function proxify(node: ASTNode, mod?: ProxifiedModule): Proxified { 17 | if (LITERALS_TYPEOF.has(typeof node)) { 18 | return node as any; 19 | } 20 | if (LITERALS_AST.has(node.type)) { 21 | return (node as any).value as any; 22 | } 23 | 24 | if (_cache.has(node)) { 25 | return _cache.get(node) as Proxified; 26 | } 27 | 28 | let proxy: ProxifiedValue; 29 | switch (node.type) { 30 | case "ObjectExpression": { 31 | proxy = proxifyObject(node, mod); 32 | break; 33 | } 34 | case "ArrayExpression": { 35 | proxy = proxifyArray(node, mod); 36 | break; 37 | } 38 | case "CallExpression": { 39 | proxy = proxifyFunctionCall(node, mod); 40 | break; 41 | } 42 | case "ArrowFunctionExpression": { 43 | proxy = proxifyArrowFunctionExpression(node, mod); 44 | break; 45 | } 46 | case "NewExpression": { 47 | proxy = proxifyNewExpression(node, mod); 48 | break; 49 | } 50 | case "Identifier": { 51 | proxy = proxifyIdentifier(node); 52 | break; 53 | } 54 | case "LogicalExpression": { 55 | proxy = proxifyLogicalExpression(node); 56 | break; 57 | } 58 | case "MemberExpression": { 59 | proxy = proxifyMemberExpression(node); 60 | break; 61 | } 62 | case "TSAsExpression": 63 | case "TSSatisfiesExpression": { 64 | proxy = proxify(node.expression, mod) as ProxifiedValue; 65 | break; 66 | } 67 | default: { 68 | throw new MagicastError(`Casting "${node.type}" is not supported`, { 69 | ast: node, 70 | code: mod?.$code, 71 | }); 72 | } 73 | } 74 | 75 | _cache.set(node, proxy); 76 | return proxy as unknown as Proxified; 77 | } 78 | -------------------------------------------------------------------------------- /src/proxy/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ImportDeclaration, 3 | ImportDefaultSpecifier, 4 | ImportNamespaceSpecifier, 5 | ImportSpecifier, 6 | } from "@babel/types"; 7 | import type { ASTNode, GenerateOptions } from "../types"; 8 | 9 | export interface ProxyBase { 10 | $ast: ASTNode; 11 | } 12 | 13 | export type ProxifiedArray = { 14 | [K in keyof T]: Proxified; 15 | } & ProxyBase & { 16 | $type: "array"; 17 | }; 18 | 19 | export type ProxifiedFunctionCall = 20 | ProxyBase & { 21 | $type: "function-call"; 22 | $args: ProxifiedArray; 23 | $callee: string; 24 | }; 25 | 26 | export type ProxifiedNewExpression = 27 | ProxyBase & { 28 | $type: "new-expression"; 29 | $args: ProxifiedArray; 30 | $callee: string; 31 | }; 32 | 33 | export type ProxifiedArrowFunctionExpression = 34 | ProxyBase & { 35 | $type: "arrow-function-expression"; 36 | $params: ProxifiedArray; 37 | $body: ProxifiedValue; 38 | }; 39 | 40 | export type ProxifiedObject = { 41 | [K in keyof T]: Proxified; 42 | } & ProxyBase & { 43 | $type: "object"; 44 | }; 45 | 46 | export type ProxifiedIdentifier = ProxyBase & { 47 | $type: "identifier"; 48 | $name: string; 49 | }; 50 | 51 | export type ProxifiedLogicalExpression = ProxyBase & { 52 | $type: "logicalExpression"; 53 | }; 54 | 55 | export type ProxifiedMemberExpression = ProxyBase & { 56 | $type: "memberExpression"; 57 | }; 58 | 59 | export type Proxified = T extends 60 | | number 61 | | string 62 | | null 63 | | undefined 64 | | boolean 65 | | bigint 66 | | symbol 67 | ? T 68 | : T extends any[] 69 | ? { 70 | [K in keyof T]: Proxified; 71 | } & ProxyBase & { 72 | $type: "array"; 73 | } 74 | : T extends object 75 | ? ProxyBase & { 76 | [K in keyof T]: Proxified; 77 | } & { 78 | $type: "object"; 79 | } 80 | : T; 81 | 82 | export type ProxifiedModule> = 83 | ProxyBase & { 84 | $type: "module"; 85 | $code: string; 86 | exports: ProxifiedObject; 87 | imports: ProxifiedImportsMap; 88 | generate: (options?: GenerateOptions) => { code: string; map?: any }; 89 | }; 90 | 91 | export type ProxifiedImportsMap = Record & 92 | ProxyBase & { 93 | $type: "imports"; 94 | /** @deprecated Use `$prepend` instead */ 95 | $add: (item: ImportItemInput) => void; 96 | $prepend: (item: ImportItemInput) => void; 97 | $append: (item: ImportItemInput) => void; 98 | $items: ProxifiedImportItem[]; 99 | }; 100 | 101 | export interface ProxifiedImportItem extends ProxyBase { 102 | $type: "import"; 103 | $ast: ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier; 104 | $declaration: ImportDeclaration; 105 | imported: string; 106 | local: string; 107 | from: string; 108 | } 109 | 110 | export interface ImportItemInput { 111 | local?: string; 112 | imported: string; 113 | from: string; 114 | } 115 | 116 | export type ProxifiedValue = 117 | | ProxifiedArray 118 | | ProxifiedFunctionCall 119 | | ProxifiedNewExpression 120 | | ProxifiedIdentifier 121 | | ProxifiedLogicalExpression 122 | | ProxifiedMemberExpression 123 | | ProxifiedObject 124 | | ProxifiedModule 125 | | ProxifiedImportsMap 126 | | ProxifiedImportItem 127 | | ProxifiedArrowFunctionExpression; 128 | 129 | export type ProxyType = ProxifiedValue["$type"]; 130 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Program } from "@babel/types"; 2 | import { Options as ParseOptions } from "recast"; 3 | import { CodeFormatOptions } from "./format"; 4 | 5 | export type { Node as ASTNode } from "@babel/types"; 6 | export * from "./proxy/types"; 7 | 8 | export interface Loc { 9 | start?: { line?: number; column?: number; token?: number }; 10 | end?: { line?: number; column?: number; token?: number }; 11 | lines?: any; 12 | } 13 | 14 | export interface Token { 15 | type: string; 16 | value: string; 17 | loc?: Loc; 18 | } 19 | 20 | export interface ParsedFileNode { 21 | type: "file"; 22 | program: Program; 23 | loc: Loc; 24 | comments: null | any; 25 | } 26 | 27 | export type GenerateOptions = ParseOptions & { 28 | format?: false | CodeFormatOptions; 29 | }; 30 | -------------------------------------------------------------------------------- /test/_utils.ts: -------------------------------------------------------------------------------- 1 | import { format } from "prettier"; 2 | import { generateCode } from "../src"; 3 | 4 | export function generate(mod: any) { 5 | return format(generateCode(mod).code, { parser: "babel-ts" }).then((code) => 6 | code.trim(), 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /test/array.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { parseModule } from "magicast"; 3 | import { generate } from "./_utils"; 4 | 5 | describe("array", () => { 6 | it("array operations", async () => { 7 | const mod = parseModule<{ default: (number | any | string)[] }>( 8 | `export default [1, 2, 3, 4, 5]`, 9 | ); 10 | 11 | expect(mod.exports.default.length).toBe(5); 12 | expect(mod.exports.default.includes(5)).toBe(true); 13 | expect(mod.exports.default.includes(6)).toBe(false); 14 | 15 | const deleted = mod.exports.default.splice(1, 3, { foo: "bar" }, "bar"); 16 | 17 | expect(deleted).toEqual([2, 3, 4]); 18 | 19 | expect(await generate(mod)).toMatchInlineSnapshot( 20 | ` 21 | "export default [ 22 | 1, 23 | { 24 | foo: "bar", 25 | }, 26 | "bar", 27 | 5, 28 | ];" 29 | `, 30 | ); 31 | 32 | const foundIndex = mod.exports.default.findIndex( 33 | (item) => item.foo === "bar", 34 | ); 35 | const found = mod.exports.default.find((item) => item.foo === "bar"); 36 | 37 | expect(foundIndex).toBe(1); 38 | expect(found).toMatchInlineSnapshot(` 39 | { 40 | "foo": "bar", 41 | } 42 | `); 43 | }); 44 | 45 | it("array should be iterable", () => { 46 | const mod = parseModule(` 47 | export const config = { 48 | array: ['a'] 49 | } 50 | `); 51 | const arr = [...mod.exports.config.array]; 52 | expect(arr).toMatchInlineSnapshot(` 53 | [ 54 | "a", 55 | ] 56 | `); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/builders/expression.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { builders, parseModule } from "magicast"; 3 | import { generate } from "../_utils"; 4 | 5 | describe("builders/expression", () => { 6 | it("new expression", async () => { 7 | const call = builders.newExpression("Foo", 1, "bar", { 8 | foo: "bar", 9 | }); 10 | expect(call.$type).toBe("new-expression"); 11 | expect(call.$callee).toBe("Foo"); 12 | expect(call.$args).toMatchInlineSnapshot(` 13 | [ 14 | 1, 15 | "bar", 16 | { 17 | "foo": "bar", 18 | }, 19 | ] 20 | `); 21 | 22 | const mod = parseModule(""); 23 | mod.exports.a = call; 24 | 25 | expect(await generate(mod)).toMatchInlineSnapshot(` 26 | "export const a = new Foo(1, "bar", { 27 | foo: "bar", 28 | });" 29 | `); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/builders/function-call.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { builders, parseModule } from "magicast"; 3 | import { generate } from "../_utils"; 4 | 5 | describe("builders/functionCall", () => { 6 | it("new", async () => { 7 | const call = builders.functionCall("functionName", 1, "bar", { 8 | foo: "bar", 9 | }); 10 | expect(call.$type).toBe("function-call"); 11 | expect(call.$callee).toBe("functionName"); 12 | expect(call.$args).toMatchInlineSnapshot(` 13 | [ 14 | 1, 15 | "bar", 16 | { 17 | "foo": "bar", 18 | }, 19 | ] 20 | `); 21 | 22 | const mod = parseModule(""); 23 | mod.exports.a = call; 24 | 25 | expect(await generate(mod)).toMatchInlineSnapshot(` 26 | "export const a = functionName(1, "bar", { 27 | foo: "bar", 28 | });" 29 | `); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/builders/raw.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { builders, parseModule } from "magicast"; 3 | import { generate } from "../_utils"; 4 | 5 | describe("builders/raw", () => { 6 | it("object", async () => { 7 | const expression = builders.raw("{ foo: 1 }"); 8 | expect(expression.$type).toBe("object"); 9 | expect(expression.foo).toBe(1); 10 | const mod = parseModule(""); 11 | mod.exports.a = expression; 12 | 13 | expect(await generate(mod)).toMatchInlineSnapshot(` 14 | "export const a = { 15 | foo: 1, 16 | };" 17 | `); 18 | }); 19 | 20 | it("identifier", async () => { 21 | const expression = builders.raw("foo"); 22 | expect(expression.$type).toBe("identifier"); 23 | expect(expression.$name).toBe("foo"); 24 | const mod = parseModule(""); 25 | mod.exports.a = expression; 26 | 27 | expect(await generate(mod)).toMatchInlineSnapshot(` 28 | "export const a = foo;" 29 | `); 30 | }); 31 | 32 | it("identifier as property", async () => { 33 | const mod = parseModule(""); 34 | mod.exports.default ||= {}; 35 | mod.exports.default.foo = builders.raw("foo"); 36 | 37 | expect(await generate(mod)).toMatchInlineSnapshot(` 38 | "export default { 39 | foo: foo, 40 | };" 41 | `); 42 | }); 43 | 44 | it("logical expression", async () => { 45 | const expression = builders.raw("foo || bar"); 46 | expect(expression.$type).toBe("logicalExpression"); 47 | const mod = parseModule(""); 48 | mod.exports.a = expression; 49 | 50 | expect(await generate(mod)).toMatchInlineSnapshot(` 51 | "export const a = foo || bar;" 52 | `); 53 | }); 54 | 55 | it("member expression", async () => { 56 | const expression = builders.raw("foo.bar"); 57 | expect(expression.$type).toBe("memberExpression"); 58 | const mod = parseModule(""); 59 | mod.exports.a = expression; 60 | 61 | expect(await generate(mod)).toMatchInlineSnapshot(` 62 | "export const a = foo.bar;" 63 | `); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/code.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from "node:fs"; 2 | import { describe, expect, it } from "vitest"; 3 | import { loadFile, parseModule, writeFile } from "../src"; 4 | 5 | describe("code", () => { 6 | it("should load and parse a file", async () => { 7 | const stub = await loadFile("./test/stubs/config.ts"); 8 | 9 | expect(stub).toBeDefined(); 10 | expect(stub.$type).toBe("module"); 11 | expect(stub.$ast).toBeDefined(); 12 | 13 | expect(stub.$code).toMatchInlineSnapshot(` 14 | "export default { 15 | foo: ["a"], 16 | }; 17 | " 18 | `); 19 | 20 | const mod = parseModule( 21 | ` 22 | export default { 23 | foo: ["a"], 24 | }; 25 | `, 26 | { sourceFileName: "./test/stubs/config.ts" }, 27 | ); 28 | 29 | expect(stub.exports).toEqual(mod.exports); 30 | }); 31 | 32 | it("should write file from a module", async () => { 33 | const mod = parseModule( 34 | ` 35 | export default { 36 | foo: ["a"], 37 | }; 38 | `, 39 | { sourceFileName: "./test/stubs/config.ts" }, 40 | ); 41 | 42 | await writeFile(mod, "./test/stubs/config2.ts"); 43 | 44 | const stub = await fsp.readFile("./test/stubs/config2.ts", "utf8"); 45 | 46 | expect(stub).toMatchInlineSnapshot(` 47 | "export default { 48 | foo: ["a"], 49 | };" 50 | `); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/errors.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from "vitest"; 2 | import { parseExpression, parseModule } from "magicast"; 3 | import { generate } from "./_utils"; 4 | 5 | describe("errors", () => { 6 | it("ternary", () => { 7 | const mod = parseModule( 8 | ` 9 | export default { 10 | a: 1 + 1 === 2 11 | ? 1 12 | : 2 13 | } 14 | `.trim(), 15 | ); 16 | 17 | expect(() => mod.exports.default.a).toThrowErrorMatchingInlineSnapshot( 18 | ` 19 | [MagicastError: Casting "ConditionalExpression" is not supported 20 | 21 | 1 | export default { 22 | 2 | a: 1 + 1 === 2 23 | ^ 24 | 3 | ? 1 25 | 4 | : 2 26 | 5 | } 27 | ] 28 | `, 29 | ); 30 | }); 31 | 32 | it("expression", () => { 33 | const mod = parseModule( 34 | ` 35 | export default { 36 | a: 1 + 1 37 | } 38 | `.trim(), 39 | ); 40 | 41 | expect(() => mod.exports.default.a).toThrowErrorMatchingInlineSnapshot( 42 | ` 43 | [MagicastError: Casting "BinaryExpression" is not supported 44 | 45 | 1 | export default { 46 | 2 | a: 1 + 1 47 | ^ 48 | 3 | } 49 | ] 50 | `, 51 | ); 52 | }); 53 | 54 | it("array destructuring", async () => { 55 | const mod = parseModule( 56 | ` 57 | export default { 58 | foo: [ 59 | 1, 60 | 2, 61 | ...foo 62 | ] 63 | } 64 | `.trim(), 65 | ); 66 | 67 | // Adding an item should work 68 | mod.exports.default.foo.push("foo"); 69 | expect(await generate(mod)).toMatchInlineSnapshot(` 70 | "export default { 71 | foo: [1, 2, ...foo, "foo"], 72 | };" 73 | `); 74 | 75 | // Iterating should throw 76 | expect(() => [ 77 | ...mod.exports.default.foo, 78 | ]).toThrowErrorMatchingInlineSnapshot( 79 | ` 80 | [MagicastError: Casting "SpreadElement" is not supported 81 | 82 | 3 | 1, 83 | 4 | 2, 84 | 5 | ...foo 85 | ^ 86 | 6 | ] 87 | 7 | } 88 | ] 89 | `, 90 | ); 91 | }); 92 | 93 | it("object destructuring", async () => { 94 | const mod = parseModule( 95 | ` 96 | export default { 97 | foo: { 98 | a: 1, 99 | ...bar 100 | } 101 | } 102 | `.trim(), 103 | ); 104 | 105 | // Adding a property should work 106 | mod.exports.default.foo.extra = "foo"; 107 | expect(await generate(mod)).toMatchInlineSnapshot(` 108 | "export default { 109 | foo: { 110 | a: 1, 111 | ...bar, 112 | extra: "foo", 113 | }, 114 | };" 115 | `); 116 | 117 | // Iterating should throw 118 | expect(() => ({ 119 | ...mod.exports.default.foo, 120 | })).toThrowErrorMatchingInlineSnapshot( 121 | ` 122 | [MagicastError: Casting "SpreadElement" is not supported 123 | 124 | 2 | foo: { 125 | 3 | a: 1, 126 | 4 | ...bar 127 | ^ 128 | 5 | } 129 | 6 | } 130 | ] 131 | `, 132 | ); 133 | }); 134 | 135 | it("parseExpression", () => { 136 | // \u0020 is used to prevent IDEs from removing the trailing space 137 | 138 | expect(() => parseExpression("foo ? {} : []")) 139 | .toThrowErrorMatchingInlineSnapshot(` 140 | [MagicastError: Casting "ConditionalExpression" is not supported 141 | 142 | 1 | foo ? {} : [] 143 | ^ 144 | ] 145 | `); 146 | 147 | const exp = parseExpression("{ a: foo ? {} : [] }"); 148 | expect(() => exp.a).toThrowErrorMatchingInlineSnapshot(` 149 | [MagicastError: Casting "ConditionalExpression" is not supported 150 | 151 | 1 | { a: foo ? {} : [] } 152 | ^ 153 | ] 154 | `); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /test/exports.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from "vitest"; 2 | import { parseModule } from "magicast"; 3 | import { generate } from "./_utils"; 4 | 5 | describe("exports", () => { 6 | it("manipulate exports", async () => { 7 | const mod = parseModule(""); 8 | 9 | expect(Object.keys(mod.exports)).toEqual([]); 10 | expect(mod.exports).toMatchInlineSnapshot(`{}`); 11 | expect(await generate(mod)).toMatchInlineSnapshot('""'); 12 | 13 | mod.exports.default = { foo: "1" }; 14 | 15 | expect(await generate(mod)).toMatchInlineSnapshot(` 16 | "export default { 17 | foo: "1", 18 | };" 19 | `); 20 | 21 | mod.exports.default.foo = 2; 22 | 23 | expect(await generate(mod)).toMatchInlineSnapshot(` 24 | "export default { 25 | foo: 2, 26 | };" 27 | `); 28 | 29 | mod.exports.named ||= []; 30 | mod.exports.named.push("a"); 31 | 32 | expect(Object.keys(mod.exports)).toEqual(["default", "named"]); 33 | 34 | expect(await generate(mod)).toMatchInlineSnapshot(` 35 | "export default { 36 | foo: 2, 37 | }; 38 | 39 | export const named = ["a"];" 40 | `); 41 | 42 | expect(Object.keys(mod)).toEqual(["imports", "exports", "generate"]); 43 | expect(JSON.stringify(mod, undefined, 2)).toMatchInlineSnapshot(` 44 | "{ 45 | "imports": {}, 46 | "exports": { 47 | "default": { 48 | "foo": 2 49 | }, 50 | "named": [ 51 | "a" 52 | ] 53 | } 54 | }" 55 | `); 56 | 57 | // delete 58 | delete mod.exports.default; 59 | 60 | expect(await generate(mod)).toMatchInlineSnapshot( 61 | `"export const named = ["a"];"`, 62 | ); 63 | 64 | delete mod.exports.named; 65 | 66 | expect(Object.keys(mod.exports)).toEqual([]); 67 | 68 | expect(await generate(mod)).toMatchInlineSnapshot('""'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/format.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from "vitest"; 2 | import { CodeFormatOptions, detectCodeFormat } from "magicast"; 3 | 4 | describe("format", () => { 5 | const cases: Array<{ 6 | name: string; 7 | code: string; 8 | todo?: boolean; 9 | format: CodeFormatOptions; 10 | }> = [ 11 | { 12 | name: "single quote", 13 | code: "console.log('hello')", 14 | format: { quote: "single" }, 15 | }, 16 | { 17 | name: "double quote", 18 | code: 'console.log("hello")', 19 | format: { quote: "double" }, 20 | }, 21 | { 22 | name: "indent 2", 23 | code: '// hello; if (test)\n {console.log("hello")\n } ', 24 | format: { tabWidth: 2, useTabs: false }, 25 | }, 26 | { 27 | name: "indent 2 + tabs", 28 | code: '// hello;\tif (test)\n\t\t{console.log("hello")\n\t} ', 29 | format: { tabWidth: 1, useTabs: true }, 30 | }, 31 | { 32 | name: "parans", 33 | code: "const test = (a) => a + 1", 34 | format: { arrowParensAlways: true }, 35 | }, 36 | { 37 | name: "no parans", 38 | code: "const test = a => a + 1", 39 | format: { arrowParensAlways: false }, 40 | }, 41 | { 42 | name: "semi", 43 | code: "console.log('hello');", 44 | format: { useSemi: true }, 45 | }, 46 | { 47 | name: "no semi", 48 | code: "console.log('hello')", 49 | format: { useSemi: false }, 50 | }, 51 | { 52 | name: "trailing comma (multi line)", 53 | code: "console.log(['hello',\n'world',\n])", 54 | format: { trailingComma: true }, 55 | }, 56 | { 57 | name: "trailing comma (single line)", 58 | code: "console.log(['hello', 'world',])", 59 | format: { trailingComma: true }, 60 | }, 61 | ]; 62 | 63 | for (const testCase of cases) { 64 | (testCase.todo ? it.todo : it)(testCase.name, () => { 65 | const detectedFormat = detectCodeFormat(testCase.code); 66 | expect(detectedFormat).toMatchObject(testCase.format); 67 | }); 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /test/function-call.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { builders, parseModule, ProxifiedModule } from "magicast"; 3 | import { generate } from "./_utils"; 4 | 5 | describe("function-calls", () => { 6 | it("function wrapper", async () => { 7 | const mod = parseModule(` 8 | export const a: any = { foo: 1} 9 | export default defineConfig({ 10 | // Modules 11 | modules: ["a"] 12 | }) 13 | `); 14 | 15 | expect(mod.exports.a.foo).toBe(1); 16 | expect(mod.exports.default.$type).toBe("function-call"); 17 | expect(mod.exports.default.$callee).toBe("defineConfig"); 18 | expect(mod.exports.default.$args).toMatchInlineSnapshot(` 19 | [ 20 | { 21 | "modules": [ 22 | "a", 23 | ], 24 | }, 25 | ] 26 | `); 27 | 28 | const options = mod.exports.default.$args[0]; 29 | 30 | options.modules ||= []; 31 | options.modules.push("b"); 32 | 33 | expect(await generate(mod)).toMatchInlineSnapshot(` 34 | "export const a: any = { foo: 1 }; 35 | export default defineConfig({ 36 | // Modules 37 | modules: ["a", "b"], 38 | });" 39 | `); 40 | }); 41 | 42 | it("construct function call", async () => { 43 | const installVuePlugin = (mod: ProxifiedModule) => { 44 | // Inject export default if not exists 45 | if (!mod.exports.default) { 46 | mod.imports.$prepend({ 47 | imported: "defineConfig", 48 | from: "vite", 49 | }); 50 | mod.exports.default = builders.functionCall("defineConfig", {}); 51 | } 52 | 53 | // Get config object, if it's a function call, get the first argument 54 | const config = 55 | mod.exports.default.$type === "function-call" 56 | ? mod.exports.default.$args[0] 57 | : mod.exports.default; 58 | 59 | // Inject vue plugin import 60 | mod.imports.$prepend({ 61 | imported: "default", 62 | local: "vuePlugin", 63 | from: "@vitejs/plugin-vue", 64 | }); 65 | 66 | // Install vue plugin 67 | config.plugins ||= []; 68 | config.plugins.push( 69 | builders.functionCall("vuePlugin", { 70 | jsx: true, 71 | }), 72 | ); 73 | }; 74 | 75 | const mod1 = parseModule(` 76 | import { defineConfig } from 'vite' 77 | 78 | export default defineConfig({}) 79 | `); 80 | const mod2 = parseModule(""); 81 | 82 | installVuePlugin(mod1); 83 | installVuePlugin(mod2); 84 | 85 | expect(await generate(mod1)).toMatchInlineSnapshot(` 86 | "import vuePlugin from "@vitejs/plugin-vue"; 87 | import { defineConfig } from "vite"; 88 | 89 | export default defineConfig({ 90 | plugins: [ 91 | vuePlugin({ 92 | jsx: true, 93 | }), 94 | ], 95 | });" 96 | `); 97 | 98 | expect(await generate(mod2)).toEqual(await generate(mod1)); 99 | }); 100 | 101 | it("arrow function parameters", () => { 102 | const mod = parseModule(` 103 | import { defineConfig } from 'vite' 104 | 105 | export default defineConfig((config) => ({ mode: "test" })) 106 | `); 107 | 108 | expect(mod.exports.default.$args[0].$params[0].$name).toBe("config"); 109 | expect(mod.exports.default.$args[0].$body.$type).toBe("object"); 110 | expect(mod.exports.default.$args[0].$body.mode).toBe("test"); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/general.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from "vitest"; 2 | import { generateCode, parseModule, parseExpression } from "magicast"; 3 | import { generate } from "./_utils"; 4 | 5 | describe("general", () => { 6 | it("basic object and array", async () => { 7 | const mod = parseModule(`export default { a: 1, b: { c: {} } }`); 8 | 9 | mod.exports.default.a = 2; 10 | 11 | expect(await generate(mod)).toMatchInlineSnapshot( 12 | '"export default { a: 2, b: { c: {} } };"', 13 | ); 14 | 15 | mod.exports.default.b.c = { d: 3 }; 16 | 17 | expect(await generate(mod)).toMatchInlineSnapshot(` 18 | "export default { 19 | a: 2, 20 | b: { 21 | c: { 22 | d: 3, 23 | }, 24 | }, 25 | };" 26 | `); 27 | 28 | expect(mod.exports.default.b.c.d).toBe(3); 29 | 30 | mod.exports.default.modules ||= []; 31 | 32 | expect(mod.exports.default.modules.$ast).toBeDefined(); 33 | 34 | expect(await generate(mod)).toMatchInlineSnapshot(` 35 | "export default { 36 | a: 2, 37 | 38 | b: { 39 | c: { 40 | d: 3, 41 | }, 42 | }, 43 | 44 | modules: [], 45 | };" 46 | `); 47 | 48 | mod.exports.default.modules.push("a"); 49 | mod.exports.default.modules.unshift({ foo: "bar" }); 50 | 51 | expect(await generate(mod)).toMatchInlineSnapshot(` 52 | "export default { 53 | a: 2, 54 | 55 | b: { 56 | c: { 57 | d: 3, 58 | }, 59 | }, 60 | 61 | modules: [ 62 | { 63 | foo: "bar", 64 | }, 65 | "a", 66 | ], 67 | };" 68 | `); 69 | 70 | expect(mod.exports.default).toMatchInlineSnapshot(` 71 | { 72 | "a": 2, 73 | "b": { 74 | "c": { 75 | "d": 3, 76 | }, 77 | }, 78 | "modules": [ 79 | { 80 | "foo": "bar", 81 | }, 82 | "a", 83 | ], 84 | } 85 | `); 86 | 87 | expect(mod.exports.default.modules.$type).toBe("array"); 88 | expect(mod.exports.default.modules[0].$type).toBe("object"); 89 | }); 90 | 91 | it("mix two configs", async () => { 92 | const mod1 = parseModule(`export default { a: 1 }`); 93 | const mod2 = parseModule(`export default { b: 2 }`); 94 | 95 | mod1.exports.default.b = mod2.exports.default; 96 | 97 | expect(await generate(mod1)).toMatchInlineSnapshot( 98 | ` 99 | "export default { 100 | a: 1, 101 | 102 | b: { 103 | b: 2, 104 | }, 105 | };" 106 | `, 107 | ); 108 | }); 109 | 110 | it("delete property", async () => { 111 | const mod = parseModule(`export default { a: 1, b: [1, { foo: 'bar' }] }`); 112 | 113 | delete mod.exports.default.b[1].foo; 114 | 115 | expect(await generate(mod)).toMatchInlineSnapshot( 116 | '"export default { a: 1, b: [1, {}] };"', 117 | ); 118 | 119 | delete mod.exports.default.b[0]; 120 | expect(await generate(mod)).toMatchInlineSnapshot( 121 | '"export default { a: 1, b: [undefined, {}] };"', 122 | ); 123 | 124 | delete mod.exports.default.a; 125 | expect(await generate(mod)).toMatchInlineSnapshot(` 126 | "export default { 127 | b: [undefined, {}], 128 | };" 129 | `); 130 | }); 131 | 132 | it("should preserve code styles", async () => { 133 | const mod = parseModule( 134 | ` 135 | export const config = { 136 | array: ['a'] 137 | } 138 | `.trim(), 139 | ); 140 | mod.exports.config.array.push("b"); 141 | expect(await generate(mod)).toMatchInlineSnapshot(` 142 | "export const config = { 143 | array: ["a", "b"], 144 | };" 145 | `); 146 | }); 147 | 148 | it("satisfies", async () => { 149 | const mod = parseModule( 150 | `export const obj = { foo: 42 } satisfies Record;`, 151 | ); 152 | 153 | mod.exports.obj.foo = 100; 154 | 155 | expect(await generate(mod)).toMatchInlineSnapshot( 156 | '"export const obj = { foo: 100 } satisfies Record;"', 157 | ); 158 | }); 159 | 160 | it("satisfies 2", async () => { 161 | const mod = parseModule(`export default {} satisfies {}`); 162 | 163 | mod.exports.default.foo = 100; 164 | 165 | expect(await generate(mod)).toMatchInlineSnapshot(` 166 | "export default { 167 | foo: 100, 168 | } satisfies {};" 169 | `); 170 | }); 171 | 172 | it("as", async () => { 173 | const mod = parseModule( 174 | `export const obj = { foo: 42 } as Record;`, 175 | ); 176 | 177 | mod.exports.obj.foo = 100; 178 | 179 | expect(await generate(mod)).toMatchInlineSnapshot( 180 | '"export const obj = { foo: 100 } as Record;"', 181 | ); 182 | }); 183 | 184 | it("as 2", async () => { 185 | const mod = parseModule(`export default {} as {}`); 186 | 187 | mod.exports.default.foo = 100; 188 | 189 | expect(await generate(mod)).toMatchInlineSnapshot(` 190 | "export default { 191 | foo: 100, 192 | } as {};" 193 | `); 194 | }); 195 | 196 | describe("parseExpression", () => { 197 | it("object", () => { 198 | const exp = parseExpression("{ a: 1, b: 2 }"); 199 | 200 | expect(exp).toEqual({ a: 1, b: 2 }); 201 | 202 | exp.a = [1, 3, 4]; 203 | 204 | expect(generateCode(exp).code).toMatchInlineSnapshot(` 205 | "{ 206 | a: [1, 3, 4], 207 | b: 2 208 | }" 209 | `); 210 | }); 211 | 212 | it("array", () => { 213 | const exp = parseExpression("[1, { foo: 2 }]"); 214 | 215 | expect(exp).toMatchInlineSnapshot(` 216 | [ 217 | 1, 218 | { 219 | "foo": 2, 220 | }, 221 | ] 222 | `); 223 | 224 | exp[2] = "foo"; 225 | 226 | expect(generateCode(exp).code).toMatchInlineSnapshot( 227 | `"[1, { foo: 2 }, "foo"]"`, 228 | ); 229 | }); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /test/helpers/nuxt.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from "vitest"; 2 | import { parseModule } from "magicast"; 3 | import { generate } from "../_utils"; 4 | import { addNuxtModule } from "magicast/helpers"; 5 | 6 | describe("helpers > nuxt", () => { 7 | it("add module", async () => { 8 | const code = `export default defineNuxtConfig({})`; 9 | const mod = parseModule(code); 10 | 11 | addNuxtModule(mod, "@vueuse/nuxt", "vueuse", { hello: "world" }); 12 | addNuxtModule(mod, "@unocss/nuxt", "unocss", { another: "config" }); 13 | 14 | expect(await generate(mod)).toMatchInlineSnapshot(` 15 | "export default defineNuxtConfig({ 16 | modules: ["@vueuse/nuxt", "@unocss/nuxt"], 17 | 18 | vueuse: { 19 | hello: "world", 20 | }, 21 | 22 | unocss: { 23 | another: "config", 24 | }, 25 | });" 26 | `); 27 | }); 28 | 29 | it("add module, keep format", () => { 30 | const code = `export default defineNuxtConfig({ 31 | modules: [ 32 | 'foo', 33 | ] 34 | })`; 35 | 36 | const mod = parseModule(code); 37 | addNuxtModule(mod, "@vueuse/nuxt", "vueuse", { hello: "world" }); 38 | addNuxtModule(mod, "@unocss/nuxt", "unocss", { another: "config" }); 39 | 40 | expect(mod.generate().code).toMatchInlineSnapshot(` 41 | "export default defineNuxtConfig({ 42 | modules: [ 43 | 'foo', 44 | '@vueuse/nuxt', 45 | '@unocss/nuxt' 46 | ], 47 | 48 | vueuse: { 49 | hello: 'world' 50 | }, 51 | 52 | unocss: { 53 | another: 'config' 54 | } 55 | })" 56 | `); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/helpers/vite.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from "vitest"; 2 | import { parseModule } from "magicast"; 3 | import { generate } from "../_utils"; 4 | import { addVitePlugin, updateVitePluginConfig } from "magicast/helpers"; 5 | 6 | describe("helpers > vite", () => { 7 | it("add plugin", async () => { 8 | const code = ` 9 | import { defineConfig } from 'vite' 10 | 11 | export default defineConfig({}) 12 | `; 13 | const mod = parseModule(code); 14 | 15 | addVitePlugin(mod, { 16 | from: "@vitejs/plugin-vue", 17 | constructor: "vuePlugin", 18 | options: { 19 | include: [/\\.vue$/, /\.md$/], 20 | }, 21 | }); 22 | 23 | addVitePlugin(mod, { 24 | from: "vite-plugin-inspect", 25 | constructor: "Inspect", 26 | options: { 27 | build: true, 28 | }, 29 | }); 30 | 31 | addVitePlugin(mod, { 32 | from: "vite-plugin-pwa", 33 | imported: "VitePWA", 34 | constructor: "VitePWA", 35 | }); 36 | 37 | updateVitePluginConfig(mod, "vite-plugin-inspect", { dev: false }); 38 | 39 | expect(await generate(mod)).toMatchInlineSnapshot(` 40 | "import { VitePWA } from "vite-plugin-pwa"; 41 | import Inspect from "vite-plugin-inspect"; 42 | import vuePlugin from "@vitejs/plugin-vue"; 43 | import { defineConfig } from "vite"; 44 | 45 | export default defineConfig({ 46 | plugins: [ 47 | vuePlugin({ 48 | include: [/\\\\.vue$/, /\\.md$/], 49 | }), 50 | Inspect({ 51 | build: true, 52 | dev: false, 53 | }), 54 | VitePWA(), 55 | ], 56 | });" 57 | `); 58 | }); 59 | 60 | it("add plugin at index", async () => { 61 | const code = ` 62 | import { defineConfig } from 'vite' 63 | import { somePlugin1, somePlugin2 } from 'some-module' 64 | 65 | export default defineConfig({ 66 | plugins: [somePlugin1(), somePlugin2()] 67 | }) 68 | `; 69 | 70 | const mod = parseModule(code); 71 | 72 | addVitePlugin(mod, { 73 | from: "@vitejs/plugin-vue", 74 | constructor: "vuePlugin", 75 | options: { 76 | include: [/\\.vue$/, /\.md$/], 77 | }, 78 | index: 0, // at the beginning 79 | }); 80 | 81 | addVitePlugin(mod, { 82 | from: "vite-plugin-inspect", 83 | constructor: "Inspect", 84 | options: { 85 | build: true, 86 | }, 87 | index: 2, // in the middle 88 | }); 89 | 90 | addVitePlugin(mod, { 91 | from: "vite-plugin-pwa", 92 | imported: "VitePWA", 93 | constructor: "VitePWA", 94 | index: 5, // at the end, out of bounds on purpose 95 | }); 96 | 97 | expect(await generate(mod)).toMatchInlineSnapshot(` 98 | "import { VitePWA } from "vite-plugin-pwa"; 99 | import Inspect from "vite-plugin-inspect"; 100 | import vuePlugin from "@vitejs/plugin-vue"; 101 | import { defineConfig } from "vite"; 102 | import { somePlugin1, somePlugin2 } from "some-module"; 103 | 104 | export default defineConfig({ 105 | plugins: [ 106 | vuePlugin({ 107 | include: [/\\\\.vue$/, /\\.md$/], 108 | }), 109 | somePlugin1(), 110 | Inspect({ 111 | build: true, 112 | }), 113 | somePlugin2(), 114 | VitePWA(), 115 | ], 116 | });" 117 | `); 118 | }); 119 | 120 | it("handles default export from identifier (fn call)", async () => { 121 | const code = ` 122 | import { defineConfig } from 'vite'; 123 | import { somePlugin1, somePlugin2 } from 'some-module' 124 | 125 | const config = defineConfig({ 126 | plugins: [somePlugin1(), somePlugin2()] 127 | }); 128 | 129 | export default config; 130 | `; 131 | 132 | const mod = parseModule(code); 133 | 134 | addVitePlugin(mod, { 135 | from: "vite-plugin-pwa", 136 | imported: "VitePWA", 137 | constructor: "VitePWA", 138 | }); 139 | 140 | expect(await generate(mod)).toMatchInlineSnapshot(` 141 | "import { VitePWA } from "vite-plugin-pwa"; 142 | import { defineConfig } from "vite"; 143 | import { somePlugin1, somePlugin2 } from "some-module"; 144 | 145 | const config = defineConfig({ 146 | plugins: [somePlugin1(), somePlugin2(), VitePWA()], 147 | }); 148 | 149 | export default config;" 150 | `); 151 | }); 152 | 153 | it("handles default export from identifier (object)", async () => { 154 | const code = ` 155 | import { somePlugin1, somePlugin2 } from 'some-module' 156 | 157 | const myConfig = { 158 | plugins: [somePlugin1(), somePlugin2()] 159 | }; 160 | 161 | export default myConfig; 162 | `; 163 | 164 | const mod = parseModule(code); 165 | 166 | addVitePlugin(mod, { 167 | index: 1, 168 | from: "vite-plugin-pwa", 169 | imported: "VitePWA", 170 | constructor: "VitePWA", 171 | }); 172 | 173 | expect(await generate(mod)).toMatchInlineSnapshot(` 174 | "import { VitePWA } from "vite-plugin-pwa"; 175 | import { somePlugin1, somePlugin2 } from "some-module"; 176 | 177 | const myConfig = { 178 | plugins: [somePlugin1(), VitePWA(), somePlugin2()], 179 | }; 180 | 181 | export default myConfig;" 182 | `); 183 | }); 184 | 185 | it("handles default export from identifier (object with satisfies)", async () => { 186 | const code = ` 187 | import { somePlugin1, somePlugin2 } from 'some-module' 188 | 189 | import type { UserConfig } from 'vite'; 190 | 191 | const myConfig = { 192 | plugins: [somePlugin1(), somePlugin2()] 193 | } satisfies UserConfig; 194 | 195 | export default myConfig; 196 | `; 197 | 198 | const mod = parseModule(code); 199 | 200 | addVitePlugin(mod, { 201 | index: 1, 202 | from: "vite-plugin-pwa", 203 | imported: "VitePWA", 204 | constructor: "VitePWA", 205 | }); 206 | 207 | expect(await generate(mod)).toMatchInlineSnapshot(` 208 | "import { VitePWA } from "vite-plugin-pwa"; 209 | import { somePlugin1, somePlugin2 } from "some-module"; 210 | 211 | import type { UserConfig } from "vite"; 212 | 213 | const myConfig = { 214 | plugins: [somePlugin1(), VitePWA(), somePlugin2()], 215 | } satisfies UserConfig; 216 | 217 | export default myConfig;" 218 | `); 219 | }); 220 | 221 | it("handles default export from identifier (function with satisfies)", async () => { 222 | const code = ` 223 | import { somePlugin1, somePlugin2 } from 'some-module' 224 | 225 | import type { UserConfig } from 'vite'; 226 | 227 | const myConfig = defineConfig({ 228 | plugins: [somePlugin1(), somePlugin2()] 229 | }) satisfies UserConfig; 230 | 231 | export default myConfig; 232 | `; 233 | 234 | const mod = parseModule(code); 235 | 236 | addVitePlugin(mod, { 237 | index: 1, 238 | from: "vite-plugin-pwa", 239 | imported: "VitePWA", 240 | constructor: "VitePWA", 241 | }); 242 | 243 | expect(await generate(mod)).toMatchInlineSnapshot(` 244 | "import { VitePWA } from "vite-plugin-pwa"; 245 | import { somePlugin1, somePlugin2 } from "some-module"; 246 | 247 | import type { UserConfig } from "vite"; 248 | 249 | const myConfig = defineConfig({ 250 | plugins: [somePlugin1(), VitePWA(), somePlugin2()], 251 | }) satisfies UserConfig; 252 | 253 | export default myConfig;" 254 | `); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /test/imports.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from "vitest"; 2 | import { parseModule } from "magicast"; 3 | import { generate } from "./_utils"; 4 | 5 | describe("imports", () => { 6 | it("manipulate imports", async () => { 7 | const mod = parseModule(` 8 | import { defineConfig, Plugin } from 'vite' 9 | import Vue from '@vitejs/plugin-vue' 10 | import * as path from 'path' 11 | 12 | export default defineConfig({ 13 | foo: [] 14 | })`); 15 | expect(mod.exports.default.$args[0]).toMatchInlineSnapshot(` 16 | { 17 | "foo": [], 18 | } 19 | `); 20 | expect(mod.imports).toMatchInlineSnapshot(` 21 | { 22 | "Plugin": { 23 | "from": "vite", 24 | "imported": "Plugin", 25 | "local": "Plugin", 26 | }, 27 | "Vue": { 28 | "from": "@vitejs/plugin-vue", 29 | "imported": "default", 30 | "local": "Vue", 31 | }, 32 | "defineConfig": { 33 | "from": "vite", 34 | "imported": "defineConfig", 35 | "local": "defineConfig", 36 | }, 37 | "path": { 38 | "from": "path", 39 | "imported": "*", 40 | "local": "path", 41 | }, 42 | } 43 | `); 44 | 45 | expect(mod.imports.path).toMatchInlineSnapshot(` 46 | { 47 | "from": "path", 48 | "imported": "*", 49 | "local": "path", 50 | } 51 | `); 52 | 53 | mod.imports.path.local = "path2"; 54 | mod.imports.Vue.local = "VuePlugin"; 55 | 56 | expect(Object.keys(mod.imports)).toMatchInlineSnapshot(` 57 | [ 58 | "defineConfig", 59 | "Plugin", 60 | "VuePlugin", 61 | "path2", 62 | ] 63 | `); 64 | 65 | delete mod.imports.Plugin; 66 | 67 | expect(await generate(mod)).toMatchInlineSnapshot(` 68 | "import { defineConfig } from "vite"; 69 | import VuePlugin from "@vitejs/plugin-vue"; 70 | import * as path2 from "path"; 71 | 72 | export default defineConfig({ 73 | foo: [], 74 | });" 75 | `); 76 | 77 | expect(await generate(mod)).toMatchInlineSnapshot(` 78 | "import { defineConfig } from "vite"; 79 | import VuePlugin from "@vitejs/plugin-vue"; 80 | import * as path2 from "path"; 81 | 82 | export default defineConfig({ 83 | foo: [], 84 | });" 85 | `); 86 | 87 | mod.imports.$prepend({ 88 | from: "foo", 89 | imported: "default", 90 | local: "Foo", 91 | }); 92 | mod.imports.$prepend({ 93 | from: "star", 94 | imported: "*", 95 | local: "Star", 96 | }); 97 | mod.imports.$prepend({ 98 | from: "vite", 99 | imported: "Good", 100 | }); 101 | mod.imports.$append({ 102 | from: "append-foo", 103 | imported: "default", 104 | local: "AppendFoo", 105 | }); 106 | mod.imports.$append({ 107 | from: "append-star", 108 | imported: "*", 109 | local: "AppendStar", 110 | }); 111 | mod.imports.$append({ 112 | from: "vite", 113 | imported: "AppendGood", 114 | }); 115 | 116 | expect(await generate(mod)).toMatchInlineSnapshot(` 117 | "import * as Star from "star"; 118 | import Foo from "foo"; 119 | import { defineConfig, Good, AppendGood } from "vite"; 120 | import VuePlugin from "@vitejs/plugin-vue"; 121 | import * as path2 from "path"; 122 | 123 | import AppendFoo from "append-foo"; 124 | import * as AppendStar from "append-star"; 125 | 126 | export default defineConfig({ 127 | foo: [], 128 | });" 129 | `); 130 | 131 | mod.imports.defineConfig.from = "vitest/config"; 132 | 133 | expect(await generate(mod)).toMatchInlineSnapshot(` 134 | "import { defineConfig } from "vitest/config"; 135 | import * as Star from "star"; 136 | import Foo from "foo"; 137 | import { Good, AppendGood } from "vite"; 138 | import VuePlugin from "@vitejs/plugin-vue"; 139 | import * as path2 from "path"; 140 | 141 | import AppendFoo from "append-foo"; 142 | import * as AppendStar from "append-star"; 143 | 144 | export default defineConfig({ 145 | foo: [], 146 | });" 147 | `); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /test/object.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { parseModule } from "../src"; 3 | import { deepMergeObject } from "../src/helpers/deep-merge"; 4 | import { generate } from "./_utils"; 5 | 6 | describe("object", () => { 7 | it("object property", async () => { 8 | const mod = parseModule( 9 | ` 10 | export default { 11 | foo: { 12 | ['a']: 1, 13 | ['a-b']: 2, 14 | foo() {}, 15 | 1: 3, 16 | [true]: 4, 17 | "c": { key: 5 }, 18 | 'd': { key: 6 }, 19 | } 20 | } 21 | `.trim(), 22 | ); 23 | 24 | expect(mod.exports.default.foo.a).toBe(1); 25 | expect(mod.exports.default.foo["a-b"]).toBe(2); 26 | expect(mod.exports.default.foo[1]).toBe(3); 27 | expect(mod.exports.default.foo.true).toBe(4); 28 | expect(mod.exports.default.foo.c?.key).toBe(5); 29 | expect(mod.exports.default.foo.d?.key).toBe(6); 30 | expect(Object.keys(mod.exports.default.foo)).toMatchInlineSnapshot(` 31 | [ 32 | "a", 33 | "a-b", 34 | "foo", 35 | "1", 36 | "true", 37 | "c", 38 | "d", 39 | ] 40 | `); 41 | 42 | mod.exports.default.foo["a-b-c"] = 3; 43 | 44 | expect(Object.keys(mod.exports.default.foo)).toMatchInlineSnapshot(` 45 | [ 46 | "a", 47 | "a-b", 48 | "foo", 49 | "1", 50 | "true", 51 | "c", 52 | "d", 53 | "a-b-c", 54 | ] 55 | `); 56 | 57 | mod.exports.default.foo["a-b"] = "updated"; 58 | 59 | expect(await generate(mod)).toMatchInlineSnapshot(` 60 | "export default { 61 | foo: { 62 | ["a"]: 1, 63 | ["a-b"]: "updated", 64 | foo() {}, 65 | 1: 3, 66 | [true]: 4, 67 | c: { key: 5 }, 68 | d: { key: 6 }, 69 | "a-b-c": 3, 70 | }, 71 | };" 72 | `); 73 | }); 74 | 75 | it("recursively create objects", async () => { 76 | const mod = parseModule( 77 | ` 78 | export default { 79 | foo: { 80 | } 81 | } 82 | `.trim(), 83 | ); 84 | 85 | // Update existing object keys 86 | expect(mod.exports.default.foo).toEqual({}); 87 | mod.exports.default.foo.value = 1; 88 | expect(mod.exports.default.foo.value).toEqual(1); 89 | 90 | // Create nested object 91 | mod.exports.default.bar = {}; 92 | mod.exports.default.bar.testValue = {}; 93 | mod.exports.default.bar.testValue.value = "a"; 94 | 95 | expect(await generate(mod)).toMatchInlineSnapshot(` 96 | "export default { 97 | foo: { 98 | value: 1, 99 | }, 100 | 101 | bar: { 102 | testValue: { 103 | value: "a", 104 | }, 105 | }, 106 | };" 107 | `); 108 | }); 109 | 110 | it("recursively merge objects", async () => { 111 | const mod = parseModule( 112 | ` 113 | export default { 114 | foo: { 115 | }, 116 | 100: 10, 117 | true: 10 118 | } 119 | `.trim(), 120 | ); 121 | 122 | const obj = { 123 | foo: { 124 | value: 1, 125 | }, 126 | 127 | bar: { 128 | testValue: { 129 | value: "a", 130 | }, 131 | }, 132 | 133 | 100: 20, 134 | 135 | true: 20, 136 | }; 137 | 138 | // Recursively merge existing object with `obj` 139 | deepMergeObject(mod.exports.default, obj); 140 | 141 | expect(await generate(mod)).toMatchInlineSnapshot(` 142 | "export default { 143 | foo: { 144 | value: 1, 145 | }, 146 | 147 | 100: 20, 148 | true: 20, 149 | 150 | bar: { 151 | testValue: { 152 | value: "a", 153 | }, 154 | }, 155 | };" 156 | `); 157 | }); 158 | 159 | it("object keys camelCase style", async () => { 160 | const mod = parseModule(`export default defineAppConfig({ 161 | test: { 162 | foo: 1, 163 | } 164 | })`); 165 | 166 | const config = 167 | mod.exports.default.$type === "function-call" 168 | ? mod.exports.default.$args[0] 169 | : mod.exports.default; 170 | 171 | const obj1 = { kebabCase: 1 }; 172 | 173 | deepMergeObject(config, obj1); 174 | 175 | expect(await generate(mod)).toMatchInlineSnapshot(` 176 | "export default defineAppConfig({ 177 | test: { 178 | foo: 1, 179 | }, 180 | 181 | kebabCase: 1, 182 | });" 183 | `); 184 | 185 | const obj2 = { kebabCaseParent: { kebabCaseChild: 1 } }; 186 | 187 | deepMergeObject(config, obj2); 188 | 189 | expect(await generate(mod)).toMatchInlineSnapshot(` 190 | "export default defineAppConfig({ 191 | test: { 192 | foo: 1, 193 | }, 194 | 195 | kebabCase: 1, 196 | 197 | kebabCaseParent: { 198 | kebabCaseChild: 1, 199 | }, 200 | });" 201 | `); 202 | }); 203 | 204 | it("object keys kebab-case style", async () => { 205 | const mod = parseModule(`export default defineAppConfig({ 206 | test: { 207 | foo: 1, 208 | } 209 | })`); 210 | 211 | const config = 212 | mod.exports.default.$type === "function-call" 213 | ? mod.exports.default.$args[0] 214 | : mod.exports.default; 215 | 216 | const obj1 = { "kebab-case": 1 }; 217 | 218 | deepMergeObject(config, obj1); 219 | 220 | // Valid 221 | expect(await generate(mod)).toMatchInlineSnapshot(` 222 | "export default defineAppConfig({ 223 | test: { 224 | foo: 1, 225 | }, 226 | 227 | "kebab-case": 1, 228 | });" 229 | `); 230 | 231 | const obj2 = { "kebab-case-parent": { "kebab-case-child": 1 } }; 232 | 233 | deepMergeObject(config, obj2); 234 | 235 | // TODO: Should be valid 236 | expect(await generate(mod)).toMatchInlineSnapshot(` 237 | "export default defineAppConfig({ 238 | test: { 239 | foo: 1, 240 | }, 241 | 242 | "kebab-case": 1, 243 | 244 | "kebab-case-parent": { 245 | "kebab-case-child": 1, 246 | }, 247 | });" 248 | `); 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /test/stubs/.gitignore: -------------------------------------------------------------------------------- 1 | config2.ts 2 | -------------------------------------------------------------------------------- /test/stubs/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | foo: ["a"], 3 | }; 4 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { print } from "recast"; 3 | import { parseModule, builders } from "magicast"; 4 | 5 | describe("literalToAst", () => { 6 | function run(value: any) { 7 | return print(builders.literal(value)).code; 8 | } 9 | 10 | it("basic", () => { 11 | expect(run(1)).toMatchInlineSnapshot('"1"'); 12 | expect(run(true)).toMatchInlineSnapshot('"true"'); 13 | expect(run(undefined)).toMatchInlineSnapshot('"undefined"'); 14 | // eslint-disable-next-line unicorn/no-null 15 | expect(run(null)).toMatchInlineSnapshot('"null"'); 16 | expect(run([undefined, 1, { foo: "bar" }])).toMatchInlineSnapshot(` 17 | "[undefined, 1, { 18 | foo: "bar" 19 | }]" 20 | `); 21 | }); 22 | 23 | it("built-in objects", () => { 24 | expect(run(new Set(["foo", 1]))).toMatchInlineSnapshot( 25 | `"new Set(["foo", 1])"`, 26 | ); 27 | 28 | expect(run(new Date("2010-01-01"))).toMatchInlineSnapshot( 29 | `"new Date("2010-01-01T00:00:00.000Z")"`, 30 | ); 31 | 32 | const map = new Map(); 33 | map.set(1, "foo"); 34 | map.set(2, "bar"); 35 | expect(run(map)).toMatchInlineSnapshot( 36 | `"new Map([[1, "foo"], [2, "bar"]])"`, 37 | ); 38 | }); 39 | 40 | it("forward proxy", () => { 41 | const mod = parseModule(`export default { foo: 1 }`); 42 | const node = mod.exports.default; 43 | 44 | expect(builders.literal(node)).toBe(node.$ast); 45 | }); 46 | 47 | it("circular reference", () => { 48 | const obj: any = {}; 49 | obj.foo = obj; 50 | expect(() => run(obj)).toThrowError("Can not serialize circular reference"); 51 | }); 52 | 53 | describe("makeProxyUtils", () => { 54 | it("trap in operator", () => { 55 | const mod = parseModule(""); 56 | const keys = ["$code", "$type", "$ast", "__magicast_proxy"]; 57 | for (const key of keys) { 58 | expect(key in mod).toBe(true); 59 | } 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "paths": { 10 | "magicast/helpers": ["./src/helpers/index.ts"], 11 | "magicast": ["./src/index.ts"] 12 | } 13 | }, 14 | "exclude": ["dist", "vendor"] 15 | } 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | recast: fileURLToPath(new URL("vendor/recast/main.ts", import.meta.url)), 8 | "ast-types": fileURLToPath( 9 | new URL("vendor/ast-types/src/main.ts", import.meta.url), 10 | ), 11 | ...(process.env.TEST_BUILD === "true" 12 | ? {} 13 | : { 14 | "magicast/helpers": fileURLToPath( 15 | new URL("src/helpers/index.ts", import.meta.url), 16 | ), 17 | magicast: fileURLToPath(new URL("src/index.ts", import.meta.url)), 18 | }), 19 | }, 20 | }, 21 | test: { 22 | name: process.env.TEST_BUILD === "true" ? "build" : "src", 23 | coverage: { 24 | include: ["src/**/*.ts"], 25 | }, 26 | }, 27 | }); 28 | --------------------------------------------------------------------------------