├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cspell.json ├── eslint.config.mts ├── images ├── chooser.png ├── code-button.png ├── commmand-palette.png ├── console-messages.png └── hotkeys.png ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── BuiltInModuleNames.ts ├── CacheInvalidationMode.ts ├── CachedModuleProxyHandler.ts ├── CodeButtonBlock.ts ├── ConsoleWrapper.ts ├── Desktop │ ├── Dependencies.ts │ ├── RequireHandler.ts │ └── ScriptFolderWatcher.ts ├── Mobile │ ├── Dependencies.ts │ ├── RequireHandler.ts │ └── ScriptFolderWatcher.ts ├── PathSuggest.ts ├── PlatformDependencies.ts ├── Plugin.ts ├── PluginSettings.ts ├── PluginSettingsManager.ts ├── PluginSettingsTab.ts ├── PluginTypes.ts ├── ProtocolHandlerComponent.ts ├── RequireHandler.ts ├── RequireHandlerUtils.ts ├── Script.ts ├── ScriptFolderWatcher.ts ├── babel │ ├── BabelPluginBase.ts │ ├── CombineBabelPlugins.ts │ ├── ConvertToCommonJsBabelPlugin.ts │ ├── ExtractRequireArgsListBabelPlugin.ts │ ├── FixSourceMapBabelPlugin.ts │ ├── WrapForCodeBlockBabelPlugin.ts │ ├── WrapInRequireFunctionBabelPlugin.ts │ └── babel-plugin-transform-import-meta.d.ts ├── main.ts └── styles │ └── main.scss ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | buy_me_a_coffee: mnaoumov 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug and help improve the plugin 3 | title: "[BUG] Short description of the bug" 4 | labels: bug 5 | assignees: mnaoumov 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Bug report 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the bug. Include any relevant details. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Steps to Reproduce 20 | description: Provide a step-by-step description. 21 | value: | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Notice that '...' 25 | ... 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Expected Behavior 31 | description: What did you expect to happen? 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Actual Behavior 37 | description: What actually happened? Include error messages if available. 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Environment Information 43 | description: Environment Information 44 | value: | 45 | - **Plugin Version**: [e.g., 1.0.0] 46 | - **Obsidian Version**: [e.g., v1.3.2] 47 | - **Operating System**: [e.g., Windows 10] 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Attachments 53 | description: Required for bug reproduction 54 | value: | 55 | - Please attach a video showing the bug. It is not mandatory, but might be very helpful to speed up the bug fix 56 | - Please attach a sample vault where the bug can be reproduced. It is not mandatory, but might be very helpful to speed up the bug fix 57 | validations: 58 | required: true 59 | - type: checkboxes 60 | attributes: 61 | label: Confirmations 62 | description: Ensure the following conditions are met 63 | options: 64 | - label: I attached a video showing the bug, or it is not necessary 65 | required: true 66 | - label: I attached a sample vault where the bug can be reproduced, or it is not necessary 67 | required: true 68 | - label: I have tested the bug with the latest version of the plugin 69 | required: true 70 | - label: I have checked GitHub for existing bugs 71 | required: true 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Request a feature and help improve the plugin 3 | title: "[FR] Short description of the feature" 4 | labels: enhancement 5 | assignees: mnaoumov 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Feature Request 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the feature request. Include any relevant details. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Details 20 | description: Provide a step-by-step description. 21 | value: | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Notice that '...' 25 | ... 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Desired Behavior 31 | description: What do you want to happen? 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Current Behavior 37 | description: What actually happens? 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Attachments 43 | description: Required for feature investigation 44 | value: | 45 | - Please attach a video showing the current behavior. It is not mandatory, but might be very helpful to speed up the feature implementation 46 | - Please attach a sample vault where the desired Feature Request could be applied. It is not mandatory, but might be very helpful to speed up the feature implementation 47 | validations: 48 | required: true 49 | - type: checkboxes 50 | attributes: 51 | label: Confirmations 52 | description: Ensure the following conditions are met 53 | options: 54 | - label: I attached a video showing the current behavior, or it is not necessary 55 | required: true 56 | - label: I attached a sample vault where the desired Feature Request could be applied, or it is not necessary 57 | required: true 58 | - label: I have tested the absence of the requested feature with the latest version of the plugin 59 | required: true 60 | - label: I have checked GitHub for existing Feature Requests 61 | required: true 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | dist 25 | 26 | /tsconfig.tsbuildinfo 27 | /.env 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 8.17.4 4 | 5 | - Update libs 6 | 7 | ## 8.17.3 8 | 9 | - Update libs 10 | 11 | ## 8.17.2 12 | 13 | - Update libs 14 | 15 | ## 8.17.1 16 | 17 | - Handle race condition 18 | - Update libs 19 | 20 | ## 8.17.0 21 | 22 | - Switch styles for dark/light theme 23 | - Show validation messages 24 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.2.0 25 | - Update CHANGELOG 26 | - Update CHANGELOG 27 | 28 | ## 8.16.2 29 | 30 | - Fixed `BRAT` and `Dataview` breakages introduced in [8.14.0](#8140) 31 | - Avoid circular calls 32 | 33 | ## 8.16.1 34 | 35 | - Attempted to fix the breakages introduced in [8.14.0](#8140) 36 | - Restore patch of Module.prototype.require 37 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.1.2 38 | 39 | ## 8.16.0 40 | 41 | - Disable protocol URLs by default 42 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.1.0 43 | 44 | ## 8.15.0 45 | 46 | - Add URL protocol handler 47 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/25.1.0 48 | 49 | ## 8.14.2 50 | 51 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.1 52 | 53 | ## 8.14.1 54 | 55 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.0 56 | 57 | ## 8.14.0 58 | 59 | - Support ASAR archives 60 | - Support all Electron modules 61 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.0.1 62 | 63 | ### Introduced breakages 64 | 65 | #### [`BRAT`](https://github.com/TfTHacker/obsidian42-brat) 66 | 67 | It starts to show the following error when you check for updates. 68 | 69 | ``` 70 | This does not seem to be an obsidian plugin with valid releases, as there are no releases available. 71 | ``` 72 | 73 | #### [Dataview](https://github.com/blacksmithgu/obsidian-dataview) 74 | 75 | `dataviewjs` queries with `require` modules 76 | 77 | ```` 78 | ```dataviewjs 79 | require('foo'); 80 | ``` 81 | ```` 82 | 83 | stops working with an error 84 | 85 | ``` 86 | Evaluation Error: Error: Cannot find module 'foo' 87 | Require stack: 88 | - electron/js2c/renderer_init 89 | at Module._resolveFilename (node:internal/modules/cjs/loader:1232:15) 90 | at a._resolveFilename (node:electron/js2c/renderer_init:2:2643) 91 | at Module._load (node:internal/modules/cjs/loader:1058:27) 92 | at c._load (node:electron/js2c/node_init:2:16955) 93 | at s._load (node:electron/js2c/renderer_init:2:30981) 94 | at Module.require (node:internal/modules/cjs/loader:1318:19) 95 | at require (node:internal/modules/helpers:179:18) 96 | at hf (app://obsidian.md/app.js:1:653429) 97 | at s (app://obsidian.md/app.js:1:2271150) 98 | at eval (eval at (plugin:dataview), :1:56) 99 | at DataviewInlineApi.eval (plugin:dataview:19027:16) 100 | at evalInContext (plugin:dataview:19028:7) 101 | at asyncEvalInContext (plugin:dataview:19038:32) 102 | at DataviewJSRenderer.render (plugin:dataview:19064:19) 103 | at DataviewJSRenderer.onload (plugin:dataview:18606:14) 104 | at DataviewJSRenderer.load (app://obsidian.md/app.js:1:1214378) 105 | at DataviewApi.executeJs (plugin:dataview:19607:18) 106 | at DataviewPlugin.dataviewjs (plugin:dataview:20537:18) 107 | at eval (plugin:dataview:20415:124) 108 | at e.createCodeBlockPostProcessor (app://obsidian.md/app.js:1:1494428) 109 | at t.postProcess (app://obsidian.md/app.js:1:1511757) 110 | at t.postProcess (app://obsidian.md/app.js:1:1510723) 111 | at h (app://obsidian.md/app.js:1:1481846) 112 | at e.onRender (app://obsidian.md/app.js:1:1482106) 113 | ``` 114 | 115 | ## 8.13.2 116 | 117 | - Add missing super call 118 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/23.0.5 119 | 120 | ## 8.13.1 121 | 122 | - Update libs 123 | - New template 124 | - Update template 125 | - New template 126 | - Update README 127 | 128 | ## 8.13.0 129 | 130 | - Add support for ~ fenced block 131 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.22.0 132 | 133 | ## 8.12.0 134 | 135 | - Allow to override module types 136 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.20.0 137 | 138 | ## 8.11.0 139 | 140 | - Add support for .node/.wasm files 141 | - Continue resolving on failed dependency 142 | - Clear timestamps on fallback 143 | - Add name of the failed dep 144 | 145 | ## 8.10.0 146 | 147 | - Fix folder check on mobile 148 | - Switch to optional options 149 | - Add synchronous fallback 150 | - `shouldUseSyncFallback` setting 151 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.19.1 152 | 153 | ## 8.9.6 154 | 155 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.2.1 156 | 157 | ## 8.9.5 158 | 159 | - Fix settings binding (thanks to @claremacrae) 160 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.1.1 161 | 162 | ## 8.9.4 163 | 164 | - Update template 165 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/18.4.2 166 | 167 | ## 8.9.3 168 | 169 | - Lint 170 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/17.2.2 171 | 172 | ## 8.9.2 173 | 174 | - Refactor to SASS 175 | 176 | ## 8.9.1 177 | 178 | - Add special module check in require() 179 | 180 | ## 8.9.0 181 | 182 | - Add special case for crypto on mobile 183 | 184 | ## 8.8.4 185 | 186 | - Format 187 | 188 | ## 8.8.3 189 | 190 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/16.1.0 191 | 192 | ## 8.8.2 193 | 194 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/16.0.3 195 | 196 | ## 8.8.1 197 | 198 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/15.0.0 199 | 200 | ## 8.8.0 201 | 202 | - Add `Reload Startup Script` command 203 | - Blur after selection 204 | - Skip validation messages 205 | - Fix refresh timeout 206 | - Add PathSuggest 207 | - Validate paths 208 | - Auto Save settings 209 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/14.3.0 210 | 211 | ## 8.7.2 212 | 213 | - Fix wrong const 214 | 215 | ## 8.7.1 216 | 217 | - Remove outdated eslint 218 | - Add Debugging / Rebranding 219 | - Fix relative wildcard resolution 220 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/13.15.0 221 | 222 | ## 8.7.0 223 | 224 | - Switch to plugin.consoleDebug 225 | - Add import.meta converters 226 | - Check all export conditions 227 | - Output error 228 | - Update imports in README 229 | - Better toJson 230 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/13.3.2 231 | - Switch to ES2024 232 | 233 | ## 8.6.0 234 | 235 | - Avoid confusing warnings 236 | 237 | ## 8.5.0 238 | 239 | - Debug successful execution 240 | - Allow disabling system messages 241 | - Don't cache empty modules 242 | 243 | ## 8.4.0 244 | 245 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/12.0.0 246 | - Fix multiple initialization 247 | - Resolve entry point 248 | - Support circular dependencies 249 | - Support nested path without exports node 250 | - Handle scoped modules 251 | - Add suffixes for relative paths 252 | 253 | ## 8.3.0 254 | 255 | - Add support for private modules 256 | - Check suffixes for missing paths 257 | 258 | ## 8.2.0 259 | 260 | - Add `autoOutput:false` 261 | 262 | ## 8.1.0 263 | 264 | - Replace `window.builtInModuleNames` with `require('obsidian/builtInModuleNames')` 265 | 266 | ## 8.0.2 267 | 268 | - Fix initial scripts initialization 269 | 270 | ## 8.0.1 271 | 272 | - Expose window.builtInModuleNames 273 | - Apply rebranding 274 | 275 | ## 8.0.0 276 | 277 | - Add renderMarkdown 278 | - Add console:false 279 | - Pass container 280 | - Add autorun 281 | - Log last value 282 | - Handle console 283 | - Better stack traces 284 | - Handle system root 285 | - Add requireAsync 286 | - Add support for nested console calls, eval, new Function() 287 | - Fix caching 288 | - Add validation 289 | - Add mobile watcher 290 | 291 | ## 7.0.0 292 | 293 | - Load/unload temp plugin 294 | - Add mobile version 295 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/11.2.0 296 | 297 | ## 6.2.2 298 | 299 | - Update libs 300 | 301 | ## 6.2.1 302 | 303 | - Update libs 304 | 305 | ## 6.2.0 306 | 307 | - Add support for '#privatePath' imports 308 | 309 | ## 6.1.0 310 | 311 | - Add cleanup() support 312 | 313 | ## 6.0.0 314 | 315 | - Force invoke name 316 | 317 | ## 5.2.3 318 | 319 | - Update libs 320 | 321 | ## 5.2.2 322 | 323 | - Fix (no caption) 324 | 325 | ## 5.2.1 326 | 327 | - Update libs 328 | 329 | ## 5.2.0 330 | 331 | - Add Clear Cache command 332 | 333 | ## 5.1.1 334 | 335 | - Fix chmod 336 | 337 | ## 5.1.0 338 | 339 | - Add Clear cache button 340 | - Fix dependencies resolution 341 | 342 | ## 5.0.5 343 | 344 | - Fix caching path 345 | 346 | ## 5.0.4 347 | 348 | - Load plugin properly 349 | 350 | ## 5.0.3 351 | 352 | - Fix esbuild first load 353 | 354 | ## 5.0.2 355 | 356 | - Fix posix paths on Windows 357 | 358 | ## 5.0.1 359 | 360 | - Fix build 361 | - Lint 362 | 363 | ## 5.0.0 364 | 365 | - Pass app to Invocables 366 | 367 | ## 4.9.1 368 | 369 | - Fix esbuild resolution 370 | 371 | ## 4.9.0 372 | 373 | - Handle folder imports 374 | - Preserve __esModule flag 375 | - Allow loading named modules from modulesRoot 376 | 377 | ## 4.8.0 378 | 379 | - Switch to obsidian-dev-utils 380 | - Add obsidian/app 381 | 382 | ## 4.7.0 383 | 384 | - Use proper path for chmod 385 | 386 | ## 4.6.0 387 | 388 | - Make binary runnable in Linux 389 | 390 | ## 4.5.0 391 | 392 | - Fix absolute paths in Linux 393 | 394 | ## 4.4.0 395 | 396 | - Fix installing from scratch 397 | 398 | ## 4.3.0 399 | 400 | - Download esbuild binaries based on the platform 401 | - Proper handle for circular dependencies in ESM 402 | 403 | ## 4.2.0 404 | 405 | - Better fix for circular dependency 406 | 407 | ## 4.1.0 408 | 409 | - Handle circular dependencies 410 | - Fix relative path 411 | 412 | ## 4.0.0 413 | 414 | - Add vault-root based require 415 | - Add currentScriptPath to dynamicImport 416 | - Support code blocks with more than 3 backticks 417 | - Fix resolve for non-relative paths 418 | - Register dynamicImport 419 | - Use babel to support top level await 420 | - Fix esbuild binary suffix 421 | 422 | ## 3.4.2 423 | 424 | - Ensure settings are loaded before patching require 425 | 426 | ## 3.4.1 427 | 428 | - Register code-button block earlier during load 429 | 430 | ## 3.4.0 431 | 432 | - Fix require absolute paths 433 | 434 | ## 3.3.0 435 | 436 | - Proper check for `require(".script.ts")` 437 | 438 | ## 3.2.1 439 | 440 | - Show notice when settings saved 441 | 442 | ## 3.2.0 443 | 444 | - Update README 445 | 446 | ## 3.1.0 447 | 448 | - Download esbuild dependencies 449 | 450 | ## 3.0.0 451 | 452 | - Watch script folder changes 453 | - Enable code highlighting 454 | - Check for script existence 455 | - Process all scripts from the config folder 456 | - Ensure stacktrace is accurate 457 | - Reload config on every invoke to ensure latest dependency 458 | - Fix timestamp check 459 | - Fix circular dependencies 460 | - Register code block 461 | - Allow both CommonJS and ESM configs 462 | - Add hotkeys button 463 | - Add save button 464 | - Fix immutability 465 | - Fix performance for missing module 466 | - Make dependency check reliable 467 | - Add support for evaled dv.view() 468 | - Invalidate cache if script changed 469 | - Properly manage nested require 470 | - Add support for local cjs 471 | 472 | ## 2.0.0 473 | 474 | - Simplify to use Module.require, expose builtInModuleNames 475 | 476 | ## 1.0.1 477 | 478 | - Initial version 479 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Naumov 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 | # CodeScript Toolkit 2 | 3 | (formerly known as `Fix Require Modules`, see [Rebranding](#rebranding) section for more details) 4 | 5 | This is a plugin for [`Obsidian`][Obsidian] that allows to do a lot of things with [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] scripts from inside the [`Obsidian`][Obsidian] itself. 6 | 7 | ## Who is this plugin for? 8 | 9 | This plugin is for you if you want to: 10 | 11 | - Write in any flavor of [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] in: 12 | - [DevTools Console](https://developer.chrome.com/docs/devtools/console) within [`Obsidian`][Obsidian]; 13 | - [CustomJS](https://github.com/saml-dev/obsidian-custom-js) scripts; 14 | - [dataviewjs](https://blacksmithgu.github.io/obsidian-dataview/api/intro/) scripts; 15 | - [Modules](https://github.com/polyipseity/obsidian-modules) scripts; 16 | - [QuickAdd](https://quickadd.obsidian.guide/) scripts; 17 | - [Templater](https://silentvoid13.github.io/Templater/) scripts; 18 | - etc. 19 | - Write modular scripts using modern [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] syntax and patterns. 20 | - Prototype [`Obsidian`][Obsidian] plugins. 21 | - Explore [`Obsidian`][Obsidian] API (public and internal) in runtime easier. 22 | - Invoke any [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] script via command or hotkey. 23 | 24 | ## Why this plugin? 25 | 26 | There are several very good plugins that allow to write [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] scripts for [`Obsidian`][Obsidian], but they all have their own limitations and quirks. 27 | 28 | Most of those plugins support writing scripts in [`CommonJS` (`cjs`)][cjs] only, which is not so used nowadays. 29 | 30 | None of those plugins provide you the developer experience as you would have in any other modern [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] development environment. 31 | 32 | This plugin aims to erase the line between the [`Obsidian`][Obsidian] world and the [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript] development world. 33 | 34 | ## New functions 35 | 36 | The plugin adds the following functions to the global scope: 37 | 38 | ```ts 39 | function require(id: string, options?: Partial); 40 | async function requireAsync(id: string, options?: Partial): Promise; 41 | async function requireAsyncWrapper((requireFn: RequireAsyncWrapperArg)): Promise; 42 | 43 | interface RequireOptions { 44 | cacheInvalidationMode: 'always' | 'never' | 'whenPossible'; 45 | moduleType?: ModuleType; 46 | parentPath?: string; 47 | } 48 | 49 | type ModuleType = 'json' | 'jsTs' | 'node' | 'wasm'; 50 | type RequireAsyncWrapperArg = (require: RequireExFn) => Promise | unknown; 51 | type RequireExFn = { parentPath?: string } & NodeJS.Require & RequireFn; 52 | type RequireFn = (id: string, options?: Partial) => unknown; 53 | ``` 54 | 55 | Explanation of the options will be shown in the [Features](#features) section. 56 | 57 | ### `require()` 58 | 59 | [`Obsidian`][Obsidian] on desktop has a built-in [`require()`][require] function, but it is quite limited. 60 | 61 | [`Obsidian`][Obsidian] on mobile does not have it at all. 62 | 63 | This plugin brings the advanced version of [`require()`][require] to both desktop and mobile. 64 | 65 | ### `requireAsync()` 66 | 67 | Combines all features of [`require()`][require] and [`import()`][import]. 68 | 69 | All features brought by this plugin are available for it. 70 | 71 | ### `requireAsyncWrapper()` 72 | 73 | Wraps synchronous `require()` calls in asynchronous ones. 74 | 75 | It is useful when you want to use the synchronous `require()` calls but some features are not available for it normally. 76 | 77 | ```js 78 | await requireAsyncWrapper((require) => { 79 | require(anyFeature); 80 | }); 81 | ``` 82 | 83 | It is especially useful for migrating scripts you have for desktop to use on mobile, as you can see in the [Features](#features) section, most of the features of `require()` don't work on mobile. 84 | 85 | ## Features 86 | 87 | For each of the feature, we provide a table showing whether the feature enabled on the platform: `Desktop` or on `Mobile`. And whether it works for `require()` or for `requireAsync()` (and `requireAsyncWrapper()`). 88 | 89 | Most of the examples below will be shown using `require()`, but you can adjust the examples to use `requireAsync()` or `requireAsyncWrapper()`, as soon as the feature is enabled for your platform 90 | 91 | ### Built-in Modules 92 | 93 | | | Desktop | Mobile | 94 | | -------------------- | ------- | ------ | 95 | | **`require()`** | ✔ | ✔ | 96 | | **`requireAsync()`** | ✔ | ✔ | 97 | 98 | Certain [`Obsidian`][Obsidian] built-in modules are available for import during plugin development but show `Uncaught Error: Cannot find module` if you try to [`require()`][require] them manually. This plugin fixes that problem, allowing the following [`require()`][require] calls to work properly: 99 | 100 | ```js 101 | require('obsidian'); 102 | require('@codemirror/autocomplete'); 103 | require('@codemirror/collab'); 104 | require('@codemirror/commands'); 105 | require('@codemirror/language'); 106 | require('@codemirror/lint'); 107 | require('@codemirror/search'); 108 | require('@codemirror/state'); 109 | require('@codemirror/text'); 110 | require('@codemirror/view'); 111 | require('@lezer/common'); 112 | require('@lezer/lr'); 113 | require('@lezer/highlight'); 114 | ``` 115 | 116 | Example usage: 117 | 118 | ```js 119 | const obsidian = require('obsidian'); 120 | new obsidian.Notice('My notice'); 121 | 122 | const { Notice } = require('obsidian'); 123 | new Notice('My notice'); 124 | ``` 125 | 126 | ### `obsidian/app` module 127 | 128 | | | Desktop | Mobile | 129 | | -------------------- | ------- | ------ | 130 | | **`require()`** | ✔ | ✔ | 131 | | **`requireAsync()`** | ✔ | ✔ | 132 | 133 | There is a global variable `app` that gives access to [`Obsidian`][Obsidian] [`App`][App] instance. 134 | 135 | However, starting from [`Obsidian`][Obsidian] [`v1.3.5`](https://github.com/obsidianmd/obsidian-api/commit/7646586acccf76f877b64111b2398938acc1d53e#diff-0eaea5db2513fdc5fe65d534d3591db5b577fe376925187c8a624124632b7466R4708) this global variable is deprecated in the public API. 136 | 137 | Starting from [`Obsidian`][Obsidian] [`v1.6.6`](https://github.com/obsidianmd/obsidian-api/commit/f20b17e38ccf12a8d7f62231255cb0608436dfbf#diff-0eaea5db2513fdc5fe65d534d3591db5b577fe376925187c8a624124632b7466L4950-L4959) this global variable was completely removed from the public API. 138 | 139 | Currently this global variable is still available, but it's better not rely on it, as it is not guaranteed to be maintained. 140 | 141 | This plugin gives you a safer alternative: 142 | 143 | ```js 144 | require('obsidian/app'); 145 | ``` 146 | 147 | ### `obsidian/builtInModuleNames` module 148 | 149 | | | Desktop | Mobile | 150 | | -------------------- | ------- | ------ | 151 | | **`require()`** | ✔ | ✔ | 152 | | **`requireAsync()`** | ✔ | ✔ | 153 | 154 | You can access the list of built-in [`Obsidian`][Obsidian] module names that are made available by this plugin. 155 | 156 | ```js 157 | require('obsidian/builtInModuleNames'); 158 | ``` 159 | 160 | ### Additional desktop modules 161 | 162 | | | Desktop | Mobile | 163 | | -------------------- | ------- | ------ | 164 | | **`require()`** | ✔ | ✖ | 165 | | **`requireAsync()`** | ✔ | ✖ | 166 | 167 | [`Obsidian`][Obsidian] on desktop is shipped with some additional modules that you can [`require()`][require]. 168 | 169 | ```js 170 | // bundled with Electron app 171 | require('electron'); 172 | require('electron/common'); 173 | require('electron/renderer'); 174 | 175 | // packed in `app.asar` 176 | require('@electron/remote'); 177 | require('btime'); 178 | require('get-fonts'); 179 | ``` 180 | 181 | ### Relative path 182 | 183 | | | Desktop | Mobile | 184 | | -------------------- | ------- | ------ | 185 | | **`require()`** | ✔ | ✖ | 186 | | **`requireAsync()`** | ✔ | ✔ | 187 | 188 | Fixes `Cannot find module` errors for relative paths: 189 | 190 | ```js 191 | require('./some/relative/path.js'); 192 | require('../some/other/relative/path.js'); 193 | ``` 194 | 195 | Optionally provide the path to the current script/note if detection fails. Submit an [issue](https://github.com/mnaoumov/obsidian-codescript-toolkit/issues) if needed: 196 | 197 | ```js 198 | require('./some/relative/path.js', { parentPath: 'path/to/current/script.js' }); 199 | require('./some/relative/path.js', { parentPath: 'path/to/current/note.md' }); 200 | ``` 201 | 202 | ### Root-relative path 203 | 204 | | | Desktop | Mobile | 205 | | -------------------- | ------- | ------ | 206 | | **`require()`** | ✔ | ✖ | 207 | | **`requireAsync()`** | ✔ | ✔ | 208 | 209 | Adds support for root-relative paths: 210 | 211 | ```js 212 | require('/path/from/root.js'); 213 | ``` 214 | 215 | The root `/` folder is configurable via settings. 216 | 217 | ### System root path (Linux, MacOS) 218 | 219 | | | Desktop | Mobile | 220 | | -------------------- | ------- | ------ | 221 | | **`require()`** | ✔ | ✖ | 222 | | **`requireAsync()`** | ✔ | ✔ | 223 | 224 | On Linux and MacOS, the system root path is `/path/from/system/root.js`. 225 | 226 | In order to distinguish them from [root-relative path](#root-relative-path), you need to prepend `~` to the path. 227 | 228 | ```js 229 | require('~/path/from/system/root.js'); 230 | ``` 231 | 232 | ### Vault-root-relative path 233 | 234 | | | Desktop | Mobile | 235 | | -------------------- | ------- | ------ | 236 | | **`require()`** | ✔ | ✖ | 237 | | **`requireAsync()`** | ✔ | ✔ | 238 | 239 | Adds support for vault-root-relative paths: 240 | 241 | ```js 242 | require('//path/from/vault/root.js'); 243 | ``` 244 | 245 | ### [`ECMAScript Modules` (`esm`)](https://nodejs.org/api/esm.html) 246 | 247 | | | Desktop | Mobile | 248 | | -------------------- | ------- | ------ | 249 | | **`require()`** | ✔ | ✖ | 250 | | **`requireAsync()`** | ✔ | ✔ | 251 | 252 | Originally, [`require()`][require] only supported [`CommonJS` (`cjs`)][cjs] modules and would throw `require() of ES Module path/to/script.mjs not supported. Instead change the require of path/to/script.mjs to a dynamic import() which is available in all CommonJS modules`. This plugin adds support for ECMAScript modules: 253 | 254 | ```js 255 | require('path/to/script.mjs'); 256 | ``` 257 | 258 | Now you can use any type of JavaScript modules: 259 | 260 | ```js 261 | require('./path/to/script.js'); 262 | require('./path/to/script.cjs'); 263 | require('./path/to/script.mjs'); 264 | ``` 265 | 266 | ### [`TypeScript`][TypeScript] modules 267 | 268 | | | Desktop | Mobile | 269 | | -------------------- | ------- | ------ | 270 | | **`require()`** | ✔ | ✖ | 271 | | **`requireAsync()`** | ✔ | ✔ | 272 | 273 | Adds support for [`TypeScript`][TypeScript] modules: 274 | 275 | ```js 276 | require('./path/to/script.ts'); 277 | require('./path/to/script.cts'); 278 | require('./path/to/script.mts'); 279 | ``` 280 | 281 | > [!WARNING] 282 | > 283 | > When the plugin loads a [`TypeScript`][TypeScript] module, it strips all type annotations and convert the code into [`JavaScript`][JavaScript] syntax. 284 | > 285 | > The plugin will report an error only if the code is syntactically incorrect. No type-checking is performed, as it done by IDEs and/or compilers. 286 | > 287 | > So you can potentially load some non-compilable [`TypeScript`][TypeScript] module, and the plugin won't report any errors. You can get runtime errors when using the module. 288 | > 289 | > It is advisable to validate your [`TypeScript`][TypeScript] modules with external IDEs and/or compilers. 290 | > 291 | > Example of such problematic module: 292 | > 293 | > ```ts 294 | > interface Foo { 295 | > bar: string; 296 | > } 297 | > 298 | > export function printFoo(foo: Foo): void { 299 | > console.log(foo.barWithTypo); // this line would cause a compilation error in a regular IDE, but the plugin won't report any errors 300 | > } 301 | > ``` 302 | > 303 | > The plugin just strips all type annotations and converts the code into [`JavaScript`][JavaScript]: 304 | > 305 | > ```js 306 | > export function printFoo(foo) { 307 | > console.log(foo.barWithTypo); 308 | > } 309 | > ``` 310 | > 311 | > So when we execute within [`Obsidian`][Obsidian]: 312 | > 313 | > ```js 314 | > require('/FooModule.ts').printFoo({ bar: 'baz' }); 315 | > ``` 316 | > 317 | > we get `undefined` instead of `baz`. 318 | 319 | ### NPM modules 320 | 321 | | | Desktop | Mobile | 322 | | -------------------- | ------- | ------ | 323 | | **`require()`** | ✔ | ✖ | 324 | | **`requireAsync()`** | ✔ | ✔ | 325 | 326 | You can require NPM modules installed into your configured scripts root folder. 327 | 328 | ```js 329 | require('npm-package-name'); 330 | ``` 331 | 332 | See [Tips](#tips) how to avoid performance issues. 333 | 334 | ### Node built-in modules 335 | 336 | | | Desktop | Mobile | 337 | | -------------------- | ------- | ------ | 338 | | **`require()`** | ✔ | ✖ | 339 | | **`requireAsync()`** | ✔ | ✖ | 340 | 341 | You can require Node built-in modules such as `fs` with an optional prefix `node:`. 342 | 343 | ```js 344 | require('fs'); 345 | require('node:fs'); 346 | ``` 347 | 348 | ### JSON files 349 | 350 | | | Desktop | Mobile | 351 | | -------------------- | ------- | ------ | 352 | | **`require()`** | ✔ | ✔ | 353 | | **`requireAsync()`** | ✔ | ✔ | 354 | 355 | You can require JSON files. 356 | 357 | ```js 358 | require('./foo.json'); 359 | ``` 360 | 361 | ### Node binaries 362 | 363 | | | Desktop | Mobile | 364 | | -------------------- | ------- | ------ | 365 | | **`require()`** | ✔ | ✖ | 366 | | **`requireAsync()`** | ✔ | ✖ | 367 | 368 | You can require Node binaries `.node`. 369 | 370 | ```js 371 | require('./foo.node'); 372 | ``` 373 | 374 | ### WebAssembly (WASM) 375 | 376 | | | Desktop | Mobile | 377 | | -------------------- | ------- | ------ | 378 | | **`require()`** | ✖ | ✖ | 379 | | **`requireAsync()`** | ✔ | ✔ | 380 | 381 | You can require WebAssembly binaries `.wasm`. 382 | 383 | ```js 384 | await requireAsync('./foo.wasm'); 385 | ``` 386 | 387 | ### ASAR Archives 388 | 389 | | | Desktop | Mobile | 390 | | -------------------- | ------- | ------ | 391 | | **`require()`** | ✔ | ✖ | 392 | | **`requireAsync()`** | ✔ | ✖ | 393 | 394 | You can require content of `.asar` files like if they were folders. 395 | 396 | ```js 397 | require('./foo.asar/bar.js'); 398 | ``` 399 | 400 | ### Override module type 401 | 402 | | | Desktop | Mobile | 403 | | -------------------- | ------- | ------ | 404 | | **`require()`** | ✔ | ✔ | 405 | | **`requireAsync()`** | ✔ | ✔ | 406 | 407 | Module type is determined via file extension. You can override it if needed. 408 | 409 | ```js 410 | require('./actual-js-file.some-unknown-extension', { moduleType: 'jsTs' }); 411 | ``` 412 | 413 | Possible values: 414 | 415 | - `json` - [JSON files](#json-files) 416 | - `jsTs` - JavaScript/TypeScript files: `.js`/`.cjs`/`.mjs`/`.ts`/`.cts`/`.mts`. 417 | - `node` - [Node binaries](#node-binaries) 418 | - `wasm` - [WebAssembly (WASM)](#webassembly-wasm) 419 | 420 | ### URLs 421 | 422 | | | Desktop | Mobile | 423 | | -------------------- | ------- | ------ | 424 | | **`require()`** | ✖ | ✖ | 425 | | **`requireAsync()`** | ✔ | ✔ | 426 | 427 | ```js 428 | await requireAsync('https://some-site.com/some-script.js'); 429 | ``` 430 | 431 | Module type is determined by `Content-Type` header returned when you fetch the url. 432 | 433 | In some cases the header is missing, incorrect or too generic like `text/plain` or `application/octet-stream`. 434 | 435 | In those cases `jsTs` module type is assumed, but it's recommended to specify it explicitly to avoid warnings. 436 | 437 | ```js 438 | await requireAsync('https://some-site.com/some-script.js', { 439 | moduleType: 'jsTs' 440 | }); 441 | ``` 442 | 443 | ### File URLs 444 | 445 | | | Desktop | Mobile | 446 | | -------------------- | ------- | ------ | 447 | | **`require()`** | ✔ | ✖ | 448 | | **`requireAsync()`** | ✔ | ✔ | 449 | 450 | You can require files using file URLs: 451 | 452 | ```js 453 | require('file:///C:/path/to/vault/then/to/script.js'); 454 | ``` 455 | 456 | ### Resource URLs 457 | 458 | | | Desktop | Mobile | 459 | | -------------------- | ------- | ------ | 460 | | **`require()`** | ✔ | ✖ | 461 | | **`requireAsync()`** | ✔ | ✔ | 462 | 463 | You can require files using resource URLs: 464 | 465 | ```js 466 | require( 467 | 'app://obsidian-resource-path-prefix/C:/path/to/vault/then/to/script.js' 468 | ); 469 | ``` 470 | 471 | See [getResourcePath()](https://docs.obsidian.md/Reference/TypeScript+API/Vault/getResourcePath) and [Platform.resourcePathPrefix](https://docs.obsidian.md/Reference/TypeScript+API/Platform#resourcePathPrefix) for more details. 472 | 473 | ### Top-level await 474 | 475 | | | Desktop | Mobile | 476 | | -------------------- | ------- | ------ | 477 | | **`require()`** | ✖ | ✖ | 478 | | **`requireAsync()`** | ✔ | ✔ | 479 | 480 | ```js 481 | // top-level-await.js 482 | await Promise.resolve(); // top-level await 483 | export const dep = 42; 484 | 485 | // script.js 486 | await requireAsync('./top-level-await.js'); 487 | ``` 488 | 489 | ### Smart caching 490 | 491 | | | Desktop | Mobile | 492 | | -------------------- | ------- | ------ | 493 | | **`require()`** | ✔ | ✔ | 494 | | **`requireAsync()`** | ✔ | ✔ | 495 | 496 | Modules are cached for performance, but the cache is invalidated if the script or its dependencies change. 497 | 498 | You can also control cache invalidation mode: 499 | 500 | ```js 501 | require('./someScript.js', { cacheInvalidationMode: 'always' }); 502 | require('./someScript.js', { cacheInvalidationMode: 'never' }); 503 | require('./someScript.js', { cacheInvalidationMode: 'whenPossible' }); 504 | ``` 505 | 506 | - `always` - always get the latest version of the module, ignoring the cached version 507 | - `never` - always use the cached version, ignoring the changes in the module, if any 508 | - `whenPossible` - get the latest version of the module if possible, otherwise use the cached version 509 | 510 | Also, you can use a query string to skip cache invalidation (except for URLs), which behaves as setting `cacheInvalidationMode` to `never`: 511 | 512 | ```js 513 | require('./someScript.js?someQuery'); // cacheInvalidationMode: 'never' 514 | require('https://some-site.com/some-script.js?someQuery'); // cacheInvalidationMode: 'whenPossible' 515 | ``` 516 | 517 | ### Clear cache 518 | 519 | | Desktop | Mobile | 520 | | ------- | ------ | 521 | | ✔ | ✔ | 522 | 523 | If you need to clear the `require` cache, you can invoke the `CodeScript Toolkit: Clear Cache` command. 524 | 525 | ### Source maps 526 | 527 | | Desktop | Mobile | 528 | | ------- | ------ | 529 | | ✔ | ✔ | 530 | 531 | Manages source maps for compiled code, allowing seamless debugging in [`Obsidian`][Obsidian]. 532 | 533 | ### Invocable scripts 534 | 535 | | Desktop | Mobile | 536 | | ------- | ------ | 537 | | ✔ | ✔ | 538 | 539 | Make any script invocable by defining a module that exports a function named `invoke` (sync or async) that accepts `app` argument of [`App`][App] type. 540 | 541 | ```ts 542 | // cjs sync 543 | exports.invoke = (app) => { 544 | console.log('cjs sync'); 545 | }; 546 | 547 | // cjs async 548 | exports.invoke = async (app) => { 549 | console.log('cjs async'); 550 | await Promise.resolve(); 551 | }; 552 | 553 | // mjs sync 554 | export function invoke(app) { 555 | console.log('mjs sync'); 556 | } 557 | 558 | // mjs async 559 | export async function invoke(app) { 560 | console.log('mjs async'); 561 | await Promise.resolve(); 562 | } 563 | 564 | // cts sync 565 | import type { App } from 'obsidian'; 566 | exports.invoke = (app: App): void => { 567 | console.log('cts sync'); 568 | }; 569 | 570 | // cts async 571 | import type { App } from 'obsidian'; 572 | exports.invoke = async (app: App): Promise => { 573 | console.log('cts async'); 574 | await Promise.resolve(); 575 | }; 576 | 577 | // mts sync 578 | import type { App } from 'obsidian'; 579 | export function invoke(app: App): void { 580 | console.log('mts sync'); 581 | } 582 | 583 | // mts async 584 | import type { App } from 'obsidian'; 585 | export async function invoke(app: App): Promise { 586 | console.log('mts async'); 587 | await Promise.resolve(); 588 | } 589 | ``` 590 | 591 | ### Invoke scripts 592 | 593 | | Desktop | Mobile | 594 | | ------- | ------ | 595 | | ✔ | ✔ | 596 | 597 | Configure a script folder so every script in it can be invoked using the [`Command Palette`][Command Palette]. Use `CodeScript Toolkit: Invoke Script: <>` for more predictable lists: 598 | 599 | ![Command Palette](images/commmand-palette.png) 600 | 601 | ![Chooser](images/chooser.png) 602 | 603 | ### Startup script 604 | 605 | | Desktop | Mobile | 606 | | ------- | ------ | 607 | | ✔ | ✔ | 608 | 609 | Invoke any script when [`Obsidian`][Obsidian] loads via a configuration setting. 610 | 611 | You can add an optional `cleanup()` function to the startup script, which will be called when the plugin is unloaded. 612 | 613 | The function has the same signature as [`invoke()`](#invocable-scripts) function. 614 | 615 | ```ts 616 | import type { App } from 'obsidian'; 617 | 618 | export async function cleanup(app: App): Promise { 619 | // executes when the plugin is unloaded 620 | } 621 | 622 | export async function invoke(app: App): Promise { 623 | // executes when the plugin is loaded, including when the app is started 624 | } 625 | ``` 626 | 627 | You can reload the startup script using the `CodeScript Toolkit: Reload Startup Script` command. 628 | 629 | ### Hotkeys 630 | 631 | | Desktop | Mobile | 632 | | ------- | ------ | 633 | | ✔ | ✖ | 634 | 635 | Assign hotkeys to frequently used scripts: 636 | 637 | ![Hotkeys](images/hotkeys.png) 638 | 639 | ### Code buttons 640 | 641 | | Desktop | Mobile | 642 | | ------- | ------ | 643 | | ✔ | ✔ | 644 | 645 | Create code buttons that execute [`JavaScript`][JavaScript]/[`TypeScript`][TypeScript]: 646 | 647 | ````markdown 648 | ```code-button "Click me!" 649 | // CommonJS (cjs) style 650 | const { dependency1 } = require('./path/to/script1.js'); 651 | 652 | // ES Modules (esm) style 653 | import { dependency2 } from './path/to/script2.js'; 654 | 655 | // Top-level await 656 | await Promise.resolve(42); 657 | 658 | // TypeScript syntax 659 | function myTypeScriptFn(arg: string): void {} 660 | ``` 661 | ```` 662 | 663 | ![Code Button](images/code-button.png) 664 | 665 | If you don't want to see the system messages such as `Executing...`, `Executed successfully`, you can set the `systemMessages` setting to `false`. 666 | 667 | ````markdown 668 | ```code-button "Click me!" systemMessages:false 669 | // code 670 | ``` 671 | ```` 672 | 673 | ### Refreshing code blocks 674 | 675 | | Desktop | Mobile | 676 | | ------- | ------ | 677 | | ✔ | ✔ | 678 | 679 | Code blocks are refreshed automatically when the content changes. 680 | 681 | If you just update settings in the code block header, the code block will not be rerendered. 682 | 683 | So your button caption and settings will not be refreshed. 684 | 685 | To fix that, you can: 686 | 687 | - Modify the code block content. 688 | - Reopen the note. 689 | - Reload the plugin. 690 | - Use the [Refresh Preview](https://obsidian.md/plugins?id=refresh-preview) plugin. 691 | 692 | ### Console messages 693 | 694 | | Desktop | Mobile | 695 | | ------- | ------ | 696 | | ✔ | ✔ | 697 | 698 | Code blocks intercept all calls to `console.debug()`, `console.error()`, `console.info()`, `console.log()`, `console.warn()` and display them in the results panel. 699 | 700 | ````markdown 701 | ```code-button "Console messages" 702 | console.debug('debug message'); 703 | console.error('error message'); 704 | console.info('info message'); 705 | console.log('log message'); 706 | console.warn('warn message'); 707 | ``` 708 | ```` 709 | 710 | ![Console messages](images/console-messages.png) 711 | 712 | If you do not want to intercept console messages, you can set the `console` setting to `false`. 713 | 714 | ````markdown 715 | ```code-button "Console messages" console:false 716 | // code 717 | ``` 718 | ```` 719 | 720 | See [Refreshing code blocks](#refreshing-code-blocks). 721 | 722 | ### Auto output 723 | 724 | | Desktop | Mobile | 725 | | ------- | ------ | 726 | | ✔ | ✖ | 727 | 728 | Code blocks automatically output the last evaluated expression. 729 | 730 | ````markdown 731 | ```code-button REPL 732 | 1 + 2; 733 | 3 + 4; 734 | 5 + 6; // this will be displayed in the results panel 735 | ``` 736 | ```` 737 | 738 | To disable this feature, set the `autoOutput` setting to `false`. 739 | 740 | ````markdown 741 | ```code-button REPL autoOutput:false 742 | 1 + 2; 743 | 3 + 4; 744 | 5 + 6; // this will NOT be displayed in the results panel 745 | ``` 746 | ```` 747 | 748 | See [Refreshing code blocks](#refreshing-code-blocks). 749 | 750 | ### Auto running code blocks 751 | 752 | | Desktop | Mobile | 753 | | ------- | ------ | 754 | | ✔ | ✖ | 755 | 756 | Code blocks can be configured to run automatically when the note is opened using the `autorun` or `autorun:true` setting. 757 | 758 | ````markdown 759 | ```code-button "Run automatically" autorun 760 | // code to run 761 | ``` 762 | ```` 763 | 764 | See [Refreshing code blocks](#refreshing-code-blocks). 765 | 766 | ### Container 767 | 768 | | Desktop | Mobile | 769 | | ------- | ------ | 770 | | ✔ | ✖ | 771 | 772 | Within code block you have access to the `container` HTML element that wraps the results panel. 773 | 774 | ````markdown 775 | ```code-button "Using container" 776 | container.createEl('button', { text: 'Click me!' }); 777 | ``` 778 | ```` 779 | 780 | ### Render markdown 781 | 782 | | Desktop | Mobile | 783 | | ------- | ------ | 784 | | ✔ | ✖ | 785 | 786 | Within code block you have access to the `renderMarkdown()` function that renders markdown in the results panel. 787 | 788 | ````markdown 789 | ```code-button "Render markdown" 790 | await renderMarkdown('**Hello, world!**'); 791 | ``` 792 | ```` 793 | 794 | ### Temp plugins 795 | 796 | | Desktop | Mobile | 797 | | ------- | ------ | 798 | | ✔ | ✔ | 799 | 800 | This plugin allows you to create temporary plugins. 801 | 802 | This is useful for quick plugin prototyping from inside the [`Obsidian`][Obsidian] itself. 803 | 804 | The key here is the function `registerTempPlugin()`, which is available in the script scope. 805 | 806 | ````markdown 807 | ```code-button "Click me!" 808 | import { Plugin } from 'obsidian'; 809 | 810 | class MyPlugin extends Plugin { 811 | onload() { 812 | console.log('loading MyPlugin'); 813 | } 814 | } 815 | 816 | registerTempPlugin(MyPlugin); 817 | ``` 818 | ```` 819 | 820 | The loaded temp plugins can be unloaded using the `CodeScript Toolkit: Unload Temp Plugin: PluginName` / `CodeScript Toolkit: Unload Temp Plugins` commands. 821 | 822 | Also all temp plugins are unloaded when current plugin is unloaded. 823 | 824 | ## Protocol URLs 825 | 826 | | Desktop | Mobile | 827 | | ------- | ------ | 828 | | ✔ | ✔ | 829 | 830 | > [!WARNING] 831 | > 832 | > This allows arbitrary code execution, which could pose a security risk. Use with caution. 833 | > Disabled by default. 834 | 835 | You can invoke script files or custom code using Obsidian URL schema. 836 | 837 | All characters has to be properly URL-escaped, e.g., you have to replace `␣` (space) with `%20`. 838 | 839 | ### Invoke script files 840 | 841 | Opening URL 842 | 843 | ``` 844 | obsidian://CodeScriptToolkit?module=/foo/bar.ts&functionName=baz&args='arg1','arg%20with%20space2',42,app.vault,%7Bbaz%3A'qux'%7D 845 | ``` 846 | 847 | would be equivalent to calling 848 | 849 | ```js 850 | const module = await requireAsync('/foo/bar.ts'); 851 | await module.baz('arg1', 'arg2 with spaces', 42, app.vault, { baz: 'qux' }); 852 | ``` 853 | 854 | If you omit `args` parameter, it will be treated as no arguments. 855 | 856 | If you omit `functionName` parameter, it will treated as `invoke`, which is useful for [Invocable scripts](#invocable-scripts). 857 | 858 | ### Invoke custom code 859 | 860 | Opening URL 861 | 862 | ``` 863 | obsidian://CodeScriptToolkit?code=await%20sleep(1000);%20console.log('foo%20bar') 864 | ``` 865 | 866 | would be equivalent to calling 867 | 868 | ```js 869 | await sleep(1000); 870 | console.log('foo bar'); 871 | ``` 872 | 873 | ## Tips 874 | 875 | | Desktop | Mobile | 876 | | ------- | ------ | 877 | | ✔ | ✔ | 878 | 879 | If you plan to use scripts extensively, consider putting them in a [`dot folder`][dot folder], such as `.scripts` within your vault. [`Obsidian`][Obsidian] doesn't track changes within [`dot folders`][dot folder] and won't re-index your `node_modules` folder repeatedly. 880 | 881 | ## Limitations 882 | 883 | ### Extending [`import()`][import] 884 | 885 | | Desktop | Mobile | 886 | | ------- | ------ | 887 | | ✔ | ✔ | 888 | 889 | Extending dynamic [`import()`][import] expressions to support `const obsidian = await import('obsidian')` is currently impossible due to [`Electron`](https://www.electronjs.org/) limitations within [`Obsidian`][Obsidian]. Although [`Obsidian`][Obsidian] [`1.6.5+`](https://obsidian.md/changelog/2024-06-25-desktop-v1.6.5/) uses [`Node.js v20.14.0`](https://nodejs.org/en/blog/release/v20.14.0) which includes [`Module.register()`][Module Register], it depends on [`Node.js Worker threads`](https://nodejs.org/api/worker_threads.html) and fails with `The V8 platform used by this instance of Node does not support creating Workers`. Use [`requireAsync()`](#requireAsync) as a workaround. 890 | 891 | ## Installation 892 | 893 | The plugin is available in [the official Community Plugins repository](https://obsidian.md/plugins?id=fix-require-modules). 894 | 895 | ### Beta versions 896 | 897 | To install the latest beta release of this plugin (regardless if it is available in [the official Community Plugins repository](https://obsidian.md/plugins) or not), follow these steps: 898 | 899 | 1. Ensure you have the [BRAT plugin](https://obsidian.md/plugins?id=obsidian42-brat) installed and enabled. 900 | 2. Click [Install via BRAT](https://intradeus.github.io/http-protocol-redirector?r=obsidian://brat?plugin=https://github.com/mnaoumov/obsidian-codescript-toolkit). 901 | 3. An Obsidian pop-up window should appear. In the window, click the `Add plugin` button once and wait a few seconds for the plugin to install. 902 | 903 | ## Debugging 904 | 905 | By default, debug messages for this plugin are hidden. 906 | 907 | To show them, run the following command: 908 | 909 | ```js 910 | window.DEBUG.enable('fix-require-modules'); 911 | ``` 912 | 913 | For more details, refer to the [documentation](https://github.com/mnaoumov/obsidian-dev-utils/blob/main/docs/debugging.md). 914 | 915 | ## Rebranding 916 | 917 | This plugin was formerly known as `Fix Require Modules`. 918 | 919 | The plugin quickly overgrew its original purpose and got way more features than just fixing `require()` calls. That's why it got a new name. 920 | 921 | However, for the backward compatibility, the previous id `fix-require-modules` is still used internally and you might find it 922 | 923 | - in plugin folder name; 924 | - in plugin URL; 925 | - in [Debugging](#debugging) section; 926 | 927 | ## Support 928 | 929 | Buy Me A Coffee 930 | 931 | ## License 932 | 933 | © [Michael Naumov](https://github.com/mnaoumov/) 934 | 935 | [App]: https://docs.obsidian.md/Reference/TypeScript+API/App 936 | [cjs]: https://nodejs.org/api/modules.html#modules-commonjs-modules 937 | [Command Palette]: https://help.obsidian.md/Plugins/Command+palette 938 | [dot folder]: https://en.wikipedia.org/wiki/Hidden_file_and_hidden_directory#Unix_and_Unix-like_environments 939 | [import]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import 940 | [JavaScript]: https://developer.mozilla.org/en-US/docs/Web/JavaScript 941 | [Module Register]: https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options 942 | [Obsidian]: https://obsidian.md/ 943 | [require]: https://nodejs.org/api/modules.html#requireid 944 | [TypeScript]: https://www.typescriptlang.org/ 945 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": [ 4 | "dist", 5 | "node_modules", 6 | "tsconfig.tsbuildinfo" 7 | ], 8 | "dictionaryDefinitions": [], 9 | "dictionaries": [], 10 | "words": [ 11 | "asar", 12 | "autorun", 13 | "Bbaz", 14 | "btime", 15 | "claremacrae", 16 | "codemirror", 17 | "collab", 18 | "Dataview", 19 | "dataviewjs", 20 | "evaled", 21 | "hotreload", 22 | "Invocables", 23 | "lezer", 24 | "loong", 25 | "mnaoumov", 26 | "nameof", 27 | "Naumov", 28 | "outfile", 29 | "posix", 30 | "postversion", 31 | "preversion", 32 | "Promisable", 33 | "riscv", 34 | "Templater", 35 | "tsbuildinfo", 36 | "Unixlike" 37 | ], 38 | "ignoreWords": [], 39 | "import": [], 40 | "enabled": true 41 | } 42 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint'; 2 | 3 | import { obsidianDevUtilsConfigs } from 'obsidian-dev-utils/ScriptUtils/ESLint/eslint.config'; 4 | 5 | const configs: Linter.Config[] = [ 6 | ...obsidianDevUtilsConfigs 7 | ]; 8 | 9 | // eslint-disable-next-line import-x/no-default-export 10 | export default configs; 11 | -------------------------------------------------------------------------------- /images/chooser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnaoumov/obsidian-codescript-toolkit/41ad0c83b2cf71438c8bc8418c889af213cc86e5/images/chooser.png -------------------------------------------------------------------------------- /images/code-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnaoumov/obsidian-codescript-toolkit/41ad0c83b2cf71438c8bc8418c889af213cc86e5/images/code-button.png -------------------------------------------------------------------------------- /images/commmand-palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnaoumov/obsidian-codescript-toolkit/41ad0c83b2cf71438c8bc8418c889af213cc86e5/images/commmand-palette.png -------------------------------------------------------------------------------- /images/console-messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnaoumov/obsidian-codescript-toolkit/41ad0c83b2cf71438c8bc8418c889af213cc86e5/images/console-messages.png -------------------------------------------------------------------------------- /images/hotkeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnaoumov/obsidian-codescript-toolkit/41ad0c83b2cf71438c8bc8418c889af213cc86e5/images/hotkeys.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "fix-require-modules", 3 | "name": "CodeScript Toolkit", 4 | "version": "8.17.4", 5 | "minAppVersion": "1.8.10", 6 | "description": "Allows to do a lot of things with JavaScript/TypeScript scripts from inside the Obsidian itself", 7 | "author": "mnaoumov", 8 | "authorUrl": "https://github.com/mnaoumov/", 9 | "isDesktopOnly": false, 10 | "fundingUrl": "https://www.buymeacoffee.com/mnaoumov" 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fix-require-modules", 3 | "version": "8.17.4", 4 | "description": "Allows to do a lot of things with JavaScript/TypeScript scripts from inside the Obsidian itself.", 5 | "scripts": { 6 | "build": "obsidian-dev-utils build", 7 | "build:clean": "obsidian-dev-utils build:clean", 8 | "build:compile": "obsidian-dev-utils build:compile", 9 | "build:compile:svelte": "obsidian-dev-utils build:compile:svelte", 10 | "build:compile:typescript": "obsidian-dev-utils build:compile:typescript", 11 | "dev": "obsidian-dev-utils dev", 12 | "format": "obsidian-dev-utils format", 13 | "format:check": "obsidian-dev-utils format:check", 14 | "lint": "obsidian-dev-utils lint", 15 | "lint:fix": "obsidian-dev-utils lint:fix", 16 | "spellcheck": "obsidian-dev-utils spellcheck", 17 | "version": "obsidian-dev-utils version" 18 | }, 19 | "keywords": [], 20 | "author": "mnaoumov", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "@babel/standalone": "^7.27.4", 24 | "@babel/types": "^7.27.3", 25 | "@types/babel__standalone": "^7.1.9", 26 | "@types/node": "^22.15.29", 27 | "babel-plugin-transform-import-meta": "^2.3.2", 28 | "debuggable-eval": "^2.1.1", 29 | "esbuild": "^0.25.5", 30 | "jiti": "^2.4.2", 31 | "obsidian": "^1.8.7", 32 | "obsidian-dev-utils": "^27.0.0", 33 | "obsidian-typings": "^3.9.5", 34 | "type-fest": "^4.41.0" 35 | }, 36 | "overrides": { 37 | "esbuild": "$esbuild" 38 | }, 39 | "type": "module" 40 | } 41 | -------------------------------------------------------------------------------- /src/BuiltInModuleNames.ts: -------------------------------------------------------------------------------- 1 | export const builtInModuleNames = [ 2 | 'obsidian', 3 | '@codemirror/autocomplete', 4 | '@codemirror/collab', 5 | '@codemirror/commands', 6 | '@codemirror/language', 7 | '@codemirror/lint', 8 | '@codemirror/search', 9 | '@codemirror/state', 10 | '@codemirror/text', 11 | '@codemirror/view', 12 | '@lezer/common', 13 | '@lezer/lr', 14 | '@lezer/highlight' 15 | ]; 16 | -------------------------------------------------------------------------------- /src/CacheInvalidationMode.ts: -------------------------------------------------------------------------------- 1 | export enum CacheInvalidationMode { 2 | Always = 'always', 3 | Never = 'never', 4 | WhenPossible = 'whenPossible' 5 | } 6 | -------------------------------------------------------------------------------- /src/CachedModuleProxyHandler.ts: -------------------------------------------------------------------------------- 1 | import { noop } from 'obsidian-dev-utils/Function'; 2 | 3 | export const EMPTY_MODULE_SYMBOL = Symbol('emptyModule'); 4 | 5 | type ApplyTarget = (this: unknown, ...args: unknown[]) => unknown; 6 | type ConstructTarget = new (...args: unknown[]) => unknown; 7 | 8 | export class CachedModuleProxyHandler implements ProxyHandler { 9 | public constructor(private readonly cachedModuleFn: () => unknown) { 10 | noop(); 11 | } 12 | 13 | public apply(_target: object, thisArg: unknown, argArray?: unknown[]): unknown { 14 | const cachedModule = this.cachedModuleFn(); 15 | if (typeof cachedModule === 'function') { 16 | return Reflect.apply(cachedModule as ApplyTarget, thisArg, argArray ?? []); 17 | } 18 | return undefined; 19 | } 20 | 21 | public construct(_target: object, argArray: unknown[], newTarget: unknown): object { 22 | const cachedModule = this.cachedModuleFn(); 23 | if (typeof cachedModule === 'function') { 24 | return Reflect.construct(cachedModule as ConstructTarget, argArray, newTarget as ConstructTarget) as object; 25 | } 26 | return {}; 27 | } 28 | 29 | public defineProperty(_target: object, property: string | symbol, attributes: PropertyDescriptor): boolean { 30 | const cachedModule = this.cachedModuleFn(); 31 | if (cachedModule && typeof cachedModule === 'object') { 32 | return Reflect.defineProperty(cachedModule, property, attributes); 33 | } 34 | return false; 35 | } 36 | 37 | public deleteProperty(_target: object, property: string | symbol): boolean { 38 | const cachedModule = this.cachedModuleFn(); 39 | if (cachedModule && typeof cachedModule === 'object') { 40 | return Reflect.deleteProperty(cachedModule, property); 41 | } 42 | return false; 43 | } 44 | 45 | public get(_target: object, property: string | symbol, receiver: unknown): unknown { 46 | if (property === EMPTY_MODULE_SYMBOL) { 47 | return true; 48 | } 49 | 50 | const cachedModule = this.cachedModuleFn(); 51 | if (cachedModule && typeof cachedModule === 'object') { 52 | return Reflect.get(cachedModule, property, receiver); 53 | } 54 | return undefined; 55 | } 56 | 57 | public getOwnPropertyDescriptor(_target: object, property: string | symbol): PropertyDescriptor | undefined { 58 | const cachedModule = this.cachedModuleFn(); 59 | if (cachedModule && typeof cachedModule === 'object') { 60 | return Reflect.getOwnPropertyDescriptor(cachedModule, property); 61 | } 62 | return undefined; 63 | } 64 | 65 | public getPrototypeOf(): null | object { 66 | const cachedModule = this.cachedModuleFn(); 67 | if (cachedModule && typeof cachedModule === 'object') { 68 | return Reflect.getPrototypeOf(cachedModule); 69 | } 70 | return null; 71 | } 72 | 73 | public has(_target: object, property: string | symbol): boolean { 74 | const cachedModule = this.cachedModuleFn(); 75 | if (cachedModule && typeof cachedModule === 'object') { 76 | return Reflect.has(cachedModule, property); 77 | } 78 | return false; 79 | } 80 | 81 | public isExtensible(): boolean { 82 | const cachedModule = this.cachedModuleFn(); 83 | if (cachedModule && typeof cachedModule === 'object') { 84 | return Reflect.isExtensible(cachedModule); 85 | } 86 | return false; 87 | } 88 | 89 | public ownKeys(): ArrayLike { 90 | const cachedModule = this.cachedModuleFn(); 91 | if (cachedModule && typeof cachedModule === 'object') { 92 | return Reflect.ownKeys(cachedModule); 93 | } 94 | return []; 95 | } 96 | 97 | public preventExtensions(): boolean { 98 | const cachedModule = this.cachedModuleFn(); 99 | if (cachedModule && typeof cachedModule === 'object') { 100 | return Reflect.preventExtensions(cachedModule); 101 | } 102 | return false; 103 | } 104 | 105 | public set(_target: object, property: string | symbol, value: unknown, receiver: unknown): boolean { 106 | const cachedModule = this.cachedModuleFn(); 107 | if (cachedModule && typeof cachedModule === 'object') { 108 | return Reflect.set(cachedModule, property, value, receiver); 109 | } 110 | return false; 111 | } 112 | 113 | public setPrototypeOf(_target: object, prototype: null | object): boolean { 114 | const cachedModule = this.cachedModuleFn(); 115 | if (cachedModule && typeof cachedModule === 'object') { 116 | return Reflect.setPrototypeOf(cachedModule, prototype); 117 | } 118 | return false; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/CodeButtonBlock.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | App, 3 | MarkdownPostProcessorContext, 4 | PluginManifest 5 | } from 'obsidian'; 6 | import type { Promisable } from 'type-fest'; 7 | 8 | import { 9 | MarkdownRenderer, 10 | Notice, 11 | Plugin 12 | } from 'obsidian'; 13 | import { invokeAsyncSafely } from 'obsidian-dev-utils/Async'; 14 | import { printError } from 'obsidian-dev-utils/Error'; 15 | import { getCodeBlockArguments } from 'obsidian-dev-utils/obsidian/MarkdownCodeBlockProcessor'; 16 | import { 17 | basename, 18 | dirname 19 | } from 'obsidian-dev-utils/Path'; 20 | 21 | import { SequentialBabelPlugin } from './babel/CombineBabelPlugins.ts'; 22 | import { ConvertToCommonJsBabelPlugin } from './babel/ConvertToCommonJsBabelPlugin.ts'; 23 | import { WrapForCodeBlockBabelPlugin } from './babel/WrapForCodeBlockBabelPlugin.ts'; 24 | import { ConsoleWrapper } from './ConsoleWrapper.ts'; 25 | import { requireStringAsync } from './RequireHandlerUtils.ts'; 26 | 27 | type CodeButtonBlockScriptWrapper = ( 28 | registerTempPlugin: RegisterTempPluginFn, 29 | console: Console, 30 | container: HTMLElement, 31 | renderMarkdown: (markdown: string) => Promise 32 | ) => Promisable; 33 | type RegisterTempPluginFn = (tempPluginClass: TempPluginClass) => void; 34 | 35 | type TempPluginClass = new (app: App, manifest: PluginManifest) => Plugin; 36 | 37 | const CODE_BUTTON_BLOCK_LANGUAGE = 'code-button'; 38 | const tempPlugins = new Map(); 39 | 40 | interface HandleClickOptions { 41 | buttonIndex: number; 42 | caption: string; 43 | plugin: Plugin; 44 | resultEl: HTMLElement; 45 | shouldAutoOutput: boolean; 46 | shouldShowSystemMessages: boolean; 47 | shouldWrapConsole: boolean; 48 | source: string; 49 | sourcePath: string; 50 | } 51 | 52 | export function registerCodeButtonBlock(plugin: Plugin): void { 53 | registerCodeHighlighting(); 54 | plugin.register(unregisterCodeHighlighting); 55 | plugin.registerMarkdownCodeBlockProcessor(CODE_BUTTON_BLOCK_LANGUAGE, (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): void => { 56 | processCodeButtonBlock(plugin, source, el, ctx); 57 | }); 58 | } 59 | 60 | export function unloadTempPlugins(): void { 61 | for (const tempPlugin of tempPlugins.values()) { 62 | tempPlugin.unload(); 63 | } 64 | } 65 | 66 | async function handleClick(options: HandleClickOptions): Promise { 67 | options.resultEl.empty(); 68 | const wrappedConsole = new ConsoleWrapper(options.resultEl); 69 | if (options.shouldShowSystemMessages) { 70 | wrappedConsole.writeSystemMessage('⏳ Executing...'); 71 | } 72 | 73 | try { 74 | const script = makeWrapperScript( 75 | options.source, 76 | `${basename(options.sourcePath)}.code-button.${options.buttonIndex.toString()}.${options.caption}.ts`, 77 | dirname(options.sourcePath), 78 | options.shouldAutoOutput 79 | ); 80 | const codeButtonBlockScriptWrapper = await requireStringAsync( 81 | script, 82 | options.plugin.app.vault.adapter.getFullPath(options.sourcePath).replaceAll('\\', '/'), 83 | `code-button:${options.buttonIndex.toString()}:${options.caption}` 84 | ) as CodeButtonBlockScriptWrapper; 85 | await codeButtonBlockScriptWrapper( 86 | makeRegisterTempPluginFn(options.plugin), 87 | wrappedConsole.getConsoleInstance(options.shouldWrapConsole), 88 | options.resultEl, 89 | makeRenderMarkdownFn(options.plugin, options.resultEl, options.sourcePath) 90 | ); 91 | if (options.shouldShowSystemMessages) { 92 | wrappedConsole.writeSystemMessage('✔ Executed successfully'); 93 | } 94 | } catch (error) { 95 | printError(error); 96 | wrappedConsole.appendToResultEl([error], 'error'); 97 | if (options.shouldShowSystemMessages) { 98 | wrappedConsole.writeSystemMessage('✖ Executed with error!'); 99 | } 100 | } 101 | } 102 | 103 | function makeRegisterTempPluginFn(plugin: Plugin): RegisterTempPluginFn { 104 | return (tempPluginClass) => { 105 | registerTempPluginImpl(plugin, tempPluginClass); 106 | }; 107 | } 108 | 109 | function makeRenderMarkdownFn(plugin: Plugin, resultEl: HTMLElement, sourcePath: string): (markdown: string) => Promise { 110 | return async (markdown: string) => { 111 | await MarkdownRenderer.render(plugin.app, markdown, resultEl, sourcePath, plugin); 112 | }; 113 | } 114 | 115 | function makeWrapperScript(source: string, sourceFileName: string, sourceFolder: string, shouldAutoOutput: boolean): string { 116 | const result = new SequentialBabelPlugin([ 117 | new ConvertToCommonJsBabelPlugin(), 118 | new WrapForCodeBlockBabelPlugin(shouldAutoOutput) 119 | ]).transform(source, sourceFileName, sourceFolder); 120 | 121 | if (result.error) { 122 | throw result.error; 123 | } 124 | 125 | return result.transformedCode; 126 | } 127 | 128 | function processCodeButtonBlock(plugin: Plugin, source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): void { 129 | const sectionInfo = ctx.getSectionInfo(el); 130 | const resultEl = el.createDiv({ cls: 'fix-require-modules console-log-container' }); 131 | 132 | if (sectionInfo) { 133 | const [ 134 | caption = '(no caption)', 135 | ...rest 136 | ] = getCodeBlockArguments(ctx, el); 137 | 138 | const shouldAutoRun = rest.includes('autorun') || rest.includes('autorun:true'); 139 | const shouldWrapConsole = !rest.includes('console:false'); 140 | const shouldAutoOutput = !rest.includes('autoOutput:false'); 141 | const shouldShowSystemMessages = !rest.includes('systemMessages:false'); 142 | 143 | const lines = sectionInfo.text.split('\n'); 144 | const previousLines = lines.slice(0, sectionInfo.lineStart); 145 | const previousText = previousLines.join('\n'); 146 | const buttonIndex = Array.from(previousText.matchAll(new RegExp(`^(?:\`{3,}|~{3,})${CODE_BUTTON_BLOCK_LANGUAGE}`, 'gm'))).length; 147 | 148 | const handleClickOptions: HandleClickOptions = { 149 | buttonIndex, 150 | caption, 151 | plugin, 152 | resultEl, 153 | shouldAutoOutput, 154 | shouldShowSystemMessages, 155 | shouldWrapConsole, 156 | source, 157 | sourcePath: ctx.sourcePath 158 | }; 159 | 160 | el.createEl('button', { 161 | cls: 'mod-cta', 162 | async onclick(): Promise { 163 | await handleClick(handleClickOptions); 164 | }, 165 | prepend: true, 166 | text: caption 167 | }); 168 | 169 | if (shouldAutoRun) { 170 | invokeAsyncSafely(() => handleClick(handleClickOptions)); 171 | } 172 | } 173 | 174 | if (!sectionInfo) { 175 | new ConsoleWrapper(resultEl).writeSystemMessage('✖ Error!\nCould not get code block info. Try to reopen the note...'); 176 | } 177 | } 178 | 179 | function registerCodeHighlighting(): void { 180 | window.CodeMirror.defineMode(CODE_BUTTON_BLOCK_LANGUAGE, (config) => window.CodeMirror.getMode(config, 'text/typescript')); 181 | } 182 | 183 | function registerTempPluginImpl(plugin: Plugin, tempPluginClass: TempPluginClass): void { 184 | const app = plugin.app; 185 | const id = `__temp-plugin-${tempPluginClass.name}`; 186 | 187 | const existingPlugin = tempPlugins.get(id); 188 | if (existingPlugin) { 189 | existingPlugin.unload(); 190 | } 191 | 192 | const tempPlugin = new tempPluginClass(app, { 193 | author: '__Temp Plugin created by Fix Require Modules', 194 | description: '__Temp Plugin created by Fix Require Modules', 195 | id, 196 | minAppVersion: '0.0.1', 197 | name: `__Temp Plugin ${tempPluginClass.name}`, 198 | version: '0.0.0' 199 | }); 200 | 201 | const unloadCommandId = `unload-temp-plugin-${tempPluginClass.name}`; 202 | 203 | tempPlugin.register(() => { 204 | tempPlugins.delete(id); 205 | plugin.removeCommand(unloadCommandId); 206 | new Notice(`Unloaded Temp Plugin: ${tempPluginClass.name}`); 207 | }); 208 | 209 | tempPlugins.set(id, tempPlugin); 210 | plugin.addChild(tempPlugin); 211 | new Notice(`Loaded Temp Plugin: ${tempPluginClass.name}`); 212 | 213 | plugin.addCommand({ 214 | callback: () => { 215 | tempPlugin.unload(); 216 | }, 217 | id: unloadCommandId, 218 | name: `Unload Temp Plugin: ${tempPluginClass.name}` 219 | }); 220 | } 221 | 222 | function unregisterCodeHighlighting(): void { 223 | window.CodeMirror.defineMode(CODE_BUTTON_BLOCK_LANGUAGE, (config) => window.CodeMirror.getMode(config, 'null')); 224 | } 225 | -------------------------------------------------------------------------------- /src/ConsoleWrapper.ts: -------------------------------------------------------------------------------- 1 | import { errorToString } from 'obsidian-dev-utils/Error'; 2 | import { noop } from 'obsidian-dev-utils/Function'; 3 | import { 4 | FunctionHandlingMode, 5 | toJson 6 | } from 'obsidian-dev-utils/Object'; 7 | 8 | type ConsoleMethod = 'debug' | 'error' | 'info' | 'log' | 'warn'; 9 | 10 | export class ConsoleWrapper { 11 | public constructor(private readonly resultEl: HTMLElement) { 12 | noop(); 13 | } 14 | 15 | public appendToResultEl(args: unknown[], method: ConsoleMethod): void { 16 | const formattedMessage = args.map(formatMessage).join(' '); 17 | this.appendToLog(formattedMessage, method); 18 | } 19 | 20 | public getConsoleInstance(shouldWrapConsole: boolean): Console { 21 | if (!shouldWrapConsole) { 22 | return console; 23 | } 24 | 25 | const wrappedConsole = { ...console }; 26 | 27 | for (const method of ['log', 'debug', 'error', 'info', 'warn'] as ConsoleMethod[]) { 28 | wrappedConsole[method] = (...args): void => { 29 | // eslint-disable-next-line no-console 30 | console[method](...args); 31 | this.appendToResultEl(args, method); 32 | }; 33 | } 34 | 35 | return wrappedConsole; 36 | } 37 | 38 | public writeSystemMessage(message: string): void { 39 | const systemMessage = this.resultEl.createDiv({ cls: 'system-message', text: message }); 40 | systemMessage.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 41 | } 42 | 43 | private appendToLog(message: string, method: ConsoleMethod): void { 44 | const logEntry = this.resultEl.createDiv({ cls: `console-log-entry console-log-entry-${method}`, text: message }); 45 | logEntry.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 46 | } 47 | } 48 | 49 | function formatMessage(arg: unknown): string { 50 | if (typeof arg === 'string') { 51 | return arg; 52 | } 53 | 54 | if (arg instanceof Error) { 55 | return errorToString(arg); 56 | } 57 | 58 | return toJson(arg, { 59 | functionHandlingMode: FunctionHandlingMode.NameOnly, 60 | maxDepth: 0, 61 | shouldCatchToJSONErrors: true, 62 | shouldHandleCircularReferences: true, 63 | shouldHandleErrors: true, 64 | shouldHandleUndefined: true, 65 | shouldSortKeys: true, 66 | tokenSubstitutions: { 67 | circularReference: '[[CircularReference]]', 68 | maxDepthLimitReached: '{...}', 69 | toJSONFailed: '[[ToJSONFailed]]' 70 | } 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /src/Desktop/Dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { PlatformDependencies } from '../PlatformDependencies.ts'; 2 | 3 | import { requireHandler } from './RequireHandler.ts'; 4 | import { scriptFolderWatcher } from './ScriptFolderWatcher.ts'; 5 | 6 | export const platformDependencies: PlatformDependencies = { 7 | requireHandler, 8 | scriptFolderWatcher 9 | }; 10 | -------------------------------------------------------------------------------- /src/Desktop/RequireHandler.ts: -------------------------------------------------------------------------------- 1 | import type { PackageJson } from 'obsidian-dev-utils/ScriptUtils/Npm'; 2 | 3 | import { FileSystemAdapter } from 'obsidian'; 4 | import { registerPatch } from 'obsidian-dev-utils/obsidian/MonkeyAround'; 5 | import { join } from 'obsidian-dev-utils/Path'; 6 | import { 7 | existsSync, 8 | Module, 9 | readFile, 10 | readFileSync, 11 | stat, 12 | statSync, 13 | tmpdir, 14 | writeFile 15 | } from 'obsidian-dev-utils/ScriptUtils/NodeModules'; 16 | import { getRootFolder } from 'obsidian-dev-utils/ScriptUtils/Root'; 17 | 18 | import type { Plugin } from '../Plugin.ts'; 19 | import type { 20 | ModuleType, 21 | PluginRequireFn, 22 | RequireFn, 23 | RequireOptions 24 | } from '../RequireHandler.ts'; 25 | 26 | import { CacheInvalidationMode } from '../CacheInvalidationMode.ts'; 27 | import { 28 | ENTRY_POINT, 29 | getModuleTypeFromPath, 30 | MODULE_NAME_SEPARATOR, 31 | NODE_MODULES_FOLDER, 32 | PATH_SUFFIXES, 33 | PRIVATE_MODULE_PREFIX, 34 | RELATIVE_MODULE_PATH_SEPARATOR, 35 | RequireHandler, 36 | ResolvedType, 37 | SCOPED_MODULE_PREFIX, 38 | splitQuery, 39 | trimNodePrefix 40 | } from '../RequireHandler.ts'; 41 | 42 | const electronModuleNames = [ 43 | 'electron', 44 | 'electron/common', 45 | 'electron/renderer' 46 | ]; 47 | 48 | const asarPackedModuleNames = [ 49 | '@electron/remote', 50 | 'btime', 51 | 'get-fonts' 52 | ]; 53 | 54 | class RequireHandlerImpl extends RequireHandler { 55 | private nodeBuiltinModules = new Set(); 56 | private originalModulePrototypeRequire!: RequireFn; 57 | 58 | private get fileSystemAdapter(): FileSystemAdapter { 59 | const adapter = this.plugin.app.vault.adapter; 60 | if (!(adapter instanceof FileSystemAdapter)) { 61 | throw new Error('Vault adapter is not a FileSystemAdapter'); 62 | } 63 | 64 | return adapter; 65 | } 66 | 67 | public override register(plugin: Plugin, pluginRequire: PluginRequireFn): void { 68 | super.register(plugin, pluginRequire); 69 | 70 | registerPatch(plugin, Module.prototype, { 71 | require: (next: RequireFn): RequireFn => { 72 | this.originalModulePrototypeRequire = next; 73 | return this.requireEx; 74 | } 75 | }); 76 | 77 | this.nodeBuiltinModules = new Set(Module.builtinModules); 78 | } 79 | 80 | public override async requireAsync(id: string, options?: Partial): Promise { 81 | try { 82 | return await super.requireAsync(id, options); 83 | } catch (e) { 84 | if (this.plugin.settings.shouldUseSyncFallback) { 85 | console.warn(`requireAsync('${id}') failed with error:`, e); 86 | console.warn('Trying a synchronous fallback'); 87 | this.currentModulesTimestampChain.clear(); 88 | return this.requireEx(id, options ?? {}); 89 | } 90 | 91 | throw e; 92 | } 93 | } 94 | 95 | protected override canRequireNonCached(type: ResolvedType): boolean { 96 | return type !== ResolvedType.Url; 97 | } 98 | 99 | protected override async existsFileAsync(path: string): Promise { 100 | return await Promise.resolve(this.existsFile(path)); 101 | } 102 | 103 | protected override async existsFolderAsync(path: string): Promise { 104 | return await Promise.resolve(this.existsFolder(path)); 105 | } 106 | 107 | protected override async getTimestampAsync(path: string): Promise { 108 | return (await stat(path)).mtimeMs; 109 | } 110 | 111 | protected override handleCodeWithTopLevelAwait(path: string): void { 112 | throw new Error(`Cannot load module: ${path}. 113 | Top-level await is not supported in sync require. 114 | Put them inside an async function or ${this.getRequireAsyncAdvice()}`); 115 | } 116 | 117 | protected override async readFileAsync(path: string): Promise { 118 | return await readFile(path, 'utf8'); 119 | } 120 | 121 | protected override async readFileBinaryAsync(path: string): Promise { 122 | const buffer = await readFile(path); 123 | const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); 124 | return arrayBuffer as ArrayBuffer; 125 | } 126 | 127 | protected override async requireNodeBinaryAsync(path: string, arrayBuffer?: ArrayBuffer): Promise { 128 | await Promise.resolve(); 129 | if (arrayBuffer) { 130 | const tmpFilePath = join(tmpdir(), `${Date.now().toString()}.node`); 131 | await this.writeFileBinaryAsync(tmpFilePath, arrayBuffer); 132 | try { 133 | return this.requireNodeBinary(tmpFilePath); 134 | } finally { 135 | await this.fileSystemAdapter.remove(tmpFilePath); 136 | } 137 | } 138 | 139 | return this.requireNodeBinary(path); 140 | } 141 | 142 | protected override requireNonCached(id: string, type: ResolvedType, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): unknown { 143 | switch (type) { 144 | case ResolvedType.Module: { 145 | const [parentFolder = '', moduleName = ''] = id.split(MODULE_NAME_SEPARATOR); 146 | return this.requireModule(moduleName, parentFolder, cacheInvalidationMode, moduleType); 147 | } 148 | case ResolvedType.Path: 149 | return this.requirePath(id, cacheInvalidationMode, moduleType); 150 | case ResolvedType.Url: 151 | throw new Error(`Cannot require synchronously from URL. ${this.getRequireAsyncAdvice(true)}`); 152 | default: 153 | throw new Error(`Unknown type: ${type as string}`); 154 | } 155 | } 156 | 157 | protected override requireSpecialModule(id: string): unknown { 158 | return super.requireSpecialModule(id) ?? this.requireNodeBuiltinModule(id) ?? this.requireElectronModule(id) ?? this.requireAsarPackedModule(id); 159 | } 160 | 161 | private existsFile(path: string): boolean { 162 | return existsSync(path) && statSync(path).isFile(); 163 | } 164 | 165 | private existsFolder(path: string): boolean { 166 | return existsSync(path) && statSync(path).isDirectory(); 167 | } 168 | 169 | private findExistingFilePath(path: string): null | string { 170 | for (const suffix of PATH_SUFFIXES) { 171 | const newPath = path + suffix; 172 | if (this.existsFile(newPath)) { 173 | return newPath; 174 | } 175 | } 176 | 177 | return null; 178 | } 179 | 180 | private getDependenciesTimestampChangedAndReloadIfNeeded(path: string, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): number { 181 | if (this.currentModulesTimestampChain.has(path)) { 182 | return this.moduleTimestamps.get(path) ?? 0; 183 | } 184 | 185 | this.currentModulesTimestampChain.add(path); 186 | 187 | const updateTimestamp = (newTimestamp: number): void => { 188 | timestamp = Math.max(timestamp, newTimestamp); 189 | this.moduleTimestamps.set(path, timestamp); 190 | }; 191 | 192 | const cachedTimestamp = this.moduleTimestamps.get(path) ?? 0; 193 | let timestamp = 0; 194 | updateTimestamp(this.getTimestamp(path)); 195 | const dependencies = this.moduleDependencies.get(path) ?? []; 196 | for (const dependency of dependencies) { 197 | const { resolvedId, resolvedType } = this.resolve(dependency, path); 198 | switch (resolvedType) { 199 | case ResolvedType.Module: 200 | for (const rootFolder of this.getRootFolders(path)) { 201 | const packageJsonPath = this.getPackageJsonPath(rootFolder); 202 | if (!this.existsFile(packageJsonPath)) { 203 | continue; 204 | } 205 | 206 | const dependencyTimestamp = this.getDependenciesTimestampChangedAndReloadIfNeeded(packageJsonPath, cacheInvalidationMode); 207 | updateTimestamp(dependencyTimestamp); 208 | } 209 | break; 210 | case ResolvedType.Path: { 211 | const existingFilePath = this.findExistingFilePath(resolvedId); 212 | if (existingFilePath === null) { 213 | continue; 214 | } 215 | 216 | const dependencyTimestamp = this.getDependenciesTimestampChangedAndReloadIfNeeded(existingFilePath, cacheInvalidationMode); 217 | updateTimestamp(dependencyTimestamp); 218 | break; 219 | } 220 | case ResolvedType.Url: { 221 | const errorMessage = this.getUrlDependencyErrorMessage(path, resolvedId, cacheInvalidationMode); 222 | switch (cacheInvalidationMode) { 223 | case CacheInvalidationMode.Always: 224 | throw new Error(errorMessage); 225 | case CacheInvalidationMode.WhenPossible: 226 | console.warn(errorMessage); 227 | break; 228 | default: 229 | throw new Error('Unknown cacheInvalidationMode'); 230 | } 231 | break; 232 | } 233 | default: 234 | throw new Error('Unknown type'); 235 | } 236 | } 237 | 238 | if (timestamp > cachedTimestamp || !this.getCachedModule(path)) { 239 | this.initModuleAndAddToCache(path, () => this.requirePathImpl(path, moduleType)); 240 | } 241 | return timestamp; 242 | } 243 | 244 | private getRootFolders(folder: string): string[] { 245 | const modulesRootFolder = this.plugin.settings.modulesRoot ? join(this.vaultAbsolutePath, this.plugin.settings.modulesRoot) : null; 246 | 247 | const ans: string[] = []; 248 | for (const possibleFolder of new Set([folder, modulesRootFolder])) { 249 | if (possibleFolder === null) { 250 | continue; 251 | } 252 | 253 | const rootFolder = getRootFolder(possibleFolder); 254 | if (rootFolder === null) { 255 | continue; 256 | } 257 | 258 | ans.push(rootFolder); 259 | } 260 | 261 | return ans; 262 | } 263 | 264 | private getTimestamp(path: string): number { 265 | return statSync(path).mtimeMs; 266 | } 267 | 268 | private getUrlDependencyErrorMessage(path: string, resolvedId: string, cacheInvalidationMode: CacheInvalidationMode): string { 269 | return `Module ${path} depends on URL ${resolvedId}. 270 | URL dependencies validation is not supported when cacheInvalidationMode=${cacheInvalidationMode}. 271 | Consider using cacheInvalidationMode=${CacheInvalidationMode.Never} or ${this.getRequireAsyncAdvice()}`; 272 | } 273 | 274 | private readFile(path: string): string { 275 | return readFileSync(path, 'utf8'); 276 | } 277 | 278 | private readPackageJson(path: string): PackageJson { 279 | const content = this.readFile(path); 280 | return JSON.parse(content) as PackageJson; 281 | } 282 | 283 | private requireAsarPackedModule(id: string): unknown { 284 | if (asarPackedModuleNames.includes(id)) { 285 | return this.originalModulePrototypeRequire(id); 286 | } 287 | 288 | return null; 289 | } 290 | 291 | private requireElectronModule(id: string): unknown { 292 | if (electronModuleNames.includes(id)) { 293 | return this.originalModulePrototypeRequire(id); 294 | } 295 | 296 | return null; 297 | } 298 | 299 | private requireJson(path: string): unknown { 300 | const jsonStr = this.readFile(splitQuery(path).cleanStr); 301 | return JSON.parse(jsonStr); 302 | } 303 | 304 | private requireJsTs(path: string): unknown { 305 | const code = this.readFile(splitQuery(path).cleanStr); 306 | return this.requireString(code, path); 307 | } 308 | 309 | private requireModule(moduleName: string, parentFolder: string, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): unknown { 310 | let separatorIndex = moduleName.indexOf(RELATIVE_MODULE_PATH_SEPARATOR); 311 | 312 | if (moduleName.startsWith(SCOPED_MODULE_PREFIX)) { 313 | if (separatorIndex === -1) { 314 | throw new Error(`Invalid scoped module name: ${moduleName}`); 315 | } 316 | separatorIndex = moduleName.indexOf(RELATIVE_MODULE_PATH_SEPARATOR, separatorIndex + 1); 317 | } 318 | 319 | const baseModuleName = separatorIndex === -1 ? moduleName : moduleName.slice(0, separatorIndex); 320 | let relativeModuleName = ENTRY_POINT + (separatorIndex === -1 ? '' : moduleName.slice(separatorIndex)); 321 | 322 | for (const rootFolder of this.getRootFolders(parentFolder)) { 323 | let packageFolder: string; 324 | if (moduleName.startsWith(PRIVATE_MODULE_PREFIX) || moduleName === ENTRY_POINT) { 325 | packageFolder = rootFolder; 326 | relativeModuleName = moduleName; 327 | } else { 328 | packageFolder = join(rootFolder, NODE_MODULES_FOLDER, baseModuleName); 329 | } 330 | 331 | if (!this.existsFolder(packageFolder)) { 332 | continue; 333 | } 334 | 335 | const packageJsonPath = this.getPackageJsonPath(packageFolder); 336 | if (!this.existsFile(packageJsonPath)) { 337 | continue; 338 | } 339 | 340 | const packageJson = this.readPackageJson(packageJsonPath); 341 | const relativeModulePaths = this.getRelativeModulePaths(packageJson, relativeModuleName); 342 | 343 | for (const relativeModulePath of relativeModulePaths) { 344 | const fullModulePath = join(packageFolder, relativeModulePath); 345 | const existingPath = this.findExistingFilePath(fullModulePath); 346 | if (!existingPath) { 347 | continue; 348 | } 349 | 350 | return this.requirePath(existingPath, cacheInvalidationMode, moduleType); 351 | } 352 | } 353 | 354 | throw new Error(`Could not resolve module: ${moduleName}`); 355 | } 356 | 357 | private requireNodeBinary(path: string): unknown { 358 | return this.originalModulePrototypeRequire(path); 359 | } 360 | 361 | private requireNodeBuiltinModule(id: string): unknown { 362 | id = trimNodePrefix(id); 363 | if (this.nodeBuiltinModules.has(id)) { 364 | return this.originalModulePrototypeRequire(id); 365 | } 366 | 367 | return null; 368 | } 369 | 370 | private requirePath(path: string, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): unknown { 371 | const existingFilePath = this.findExistingFilePath(path); 372 | if (existingFilePath === null) { 373 | throw new Error(`File not found: ${path}`); 374 | } 375 | 376 | const isRootRequire = this.currentModulesTimestampChain.size === 0; 377 | 378 | try { 379 | this.getDependenciesTimestampChangedAndReloadIfNeeded(existingFilePath, cacheInvalidationMode, moduleType); 380 | } finally { 381 | if (isRootRequire) { 382 | this.currentModulesTimestampChain.clear(); 383 | } 384 | } 385 | return this.modulesCache[existingFilePath]?.exports; 386 | } 387 | 388 | private requirePathImpl(path: string, moduleType?: ModuleType): unknown { 389 | moduleType ??= getModuleTypeFromPath(path); 390 | switch (moduleType) { 391 | case 'json': 392 | return this.requireJson(path); 393 | case 'jsTs': 394 | return this.requireJsTs(path); 395 | case 'node': 396 | return this.requireNodeBinary(path); 397 | case 'wasm': 398 | return this.requireWasm(); 399 | default: 400 | throw new Error(`Unknown module type: ${moduleType as string}`); 401 | } 402 | } 403 | 404 | private requireString(code: string, path: string): unknown { 405 | try { 406 | return this.initModuleAndAddToCache(path, () => { 407 | const result = this.requireStringImpl({ 408 | code, 409 | evalPrefix: 'requireString', 410 | path, 411 | shouldWrapInAsyncFunction: false, 412 | urlSuffix: '' 413 | }); 414 | return result.exportsFn(); 415 | }); 416 | } catch (e) { 417 | throw new Error(`Failed to load module: ${path}`, { cause: e }); 418 | } 419 | } 420 | 421 | private requireWasm(): unknown { 422 | throw new Error(`Cannot require WASM synchronously. ${this.getRequireAsyncAdvice(true)}`); 423 | } 424 | 425 | private async writeFileBinaryAsync(path: string, arrayBuffer: ArrayBuffer): Promise { 426 | const buffer = Buffer.from(arrayBuffer); 427 | await writeFile(path, buffer); 428 | } 429 | } 430 | 431 | export const requireHandler = new RequireHandlerImpl(); 432 | -------------------------------------------------------------------------------- /src/Desktop/ScriptFolderWatcher.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FSWatcher, 3 | WatchEventType 4 | } from 'obsidian-dev-utils/ScriptUtils/NodeModules'; 5 | 6 | import { Notice } from 'obsidian'; 7 | import { invokeAsyncSafely } from 'obsidian-dev-utils/Async'; 8 | import { join } from 'obsidian-dev-utils/Path'; 9 | import { watch } from 'obsidian-dev-utils/ScriptUtils/NodeModules'; 10 | 11 | import { ScriptFolderWatcher } from '../ScriptFolderWatcher.ts'; 12 | 13 | class ScriptFolderWatcherImpl extends ScriptFolderWatcher { 14 | private watcher: FSWatcher | null = null; 15 | 16 | protected override async startWatcher(onChange: () => Promise): Promise { 17 | const invocableScriptsFolder = this.plugin.settings.getInvocableScriptsFolder(); 18 | if (!invocableScriptsFolder) { 19 | return false; 20 | } 21 | 22 | if (!(await this.plugin.app.vault.exists(invocableScriptsFolder))) { 23 | const message = `Invocable scripts folder not found: ${invocableScriptsFolder}`; 24 | new Notice(message); 25 | console.error(message); 26 | return false; 27 | } 28 | 29 | const invocableScriptsFolderFullPath = join(this.plugin.app.vault.adapter.basePath, invocableScriptsFolder); 30 | this.watcher = watch(invocableScriptsFolderFullPath, { recursive: true }, (eventType: WatchEventType): void => { 31 | if (eventType === 'rename') { 32 | invokeAsyncSafely(() => onChange()); 33 | } 34 | }); 35 | 36 | return true; 37 | } 38 | 39 | protected override stopWatcher(): void { 40 | if (this.watcher) { 41 | this.watcher.close(); 42 | this.watcher = null; 43 | } 44 | } 45 | } 46 | 47 | export const scriptFolderWatcher: ScriptFolderWatcher = new ScriptFolderWatcherImpl(); 48 | -------------------------------------------------------------------------------- /src/Mobile/Dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { PlatformDependencies } from '../PlatformDependencies.ts'; 2 | 3 | import { requireHandler } from './RequireHandler.ts'; 4 | import { scriptFolderWatcher } from './ScriptFolderWatcher.ts'; 5 | 6 | export const platformDependencies: PlatformDependencies = { 7 | requireHandler, 8 | scriptFolderWatcher 9 | }; 10 | -------------------------------------------------------------------------------- /src/Mobile/RequireHandler.ts: -------------------------------------------------------------------------------- 1 | import { CapacitorAdapter } from 'obsidian'; 2 | 3 | import { 4 | MODULE_TO_SKIP, 5 | RequireHandler, 6 | trimNodePrefix 7 | } from '../RequireHandler.ts'; 8 | 9 | class RequireHandlerImpl extends RequireHandler { 10 | private get capacitorAdapter(): CapacitorAdapter { 11 | const adapter = this.plugin.app.vault.adapter; 12 | if (!(adapter instanceof CapacitorAdapter)) { 13 | throw new Error('Vault adapter is not a CapacitorAdapter'); 14 | } 15 | 16 | return adapter; 17 | } 18 | 19 | protected override canRequireNonCached(): boolean { 20 | return false; 21 | } 22 | 23 | protected override async existsFileAsync(path: string): Promise { 24 | if (!await this.capacitorAdapter.fs.exists(path)) { 25 | return false; 26 | } 27 | 28 | const stat = await this.capacitorAdapter.fs.stat(path); 29 | return stat.type === 'file'; 30 | } 31 | 32 | protected override async existsFolderAsync(path: string): Promise { 33 | if (!await this.capacitorAdapter.fs.exists(path)) { 34 | return false; 35 | } 36 | 37 | const stat = await this.capacitorAdapter.fs.stat(path); 38 | return stat.type === 'directory'; 39 | } 40 | 41 | protected override async getTimestampAsync(path: string): Promise { 42 | const stat = await this.capacitorAdapter.fs.stat(path); 43 | return stat.mtime ?? 0; 44 | } 45 | 46 | protected override async readFileAsync(path: string): Promise { 47 | return await this.capacitorAdapter.fs.read(path); 48 | } 49 | 50 | protected override async readFileBinaryAsync(path: string): Promise { 51 | return await this.capacitorAdapter.fs.readBinary(path); 52 | } 53 | 54 | protected override async requireNodeBinaryAsync(): Promise { 55 | await Promise.resolve(); 56 | throw new Error('Cannot require node binary on mobile'); 57 | } 58 | 59 | protected override requireNonCached(): unknown { 60 | throw new Error('Cannot require synchronously on mobile'); 61 | } 62 | 63 | protected override requireSpecialModule(id: string): unknown { 64 | const module = super.requireSpecialModule(id); 65 | if (module) { 66 | return module; 67 | } 68 | 69 | if (trimNodePrefix(id) === 'crypto') { 70 | console.warn('Crypto module is not available on mobile. Consider using window.scrypt instead'); 71 | return MODULE_TO_SKIP; 72 | } 73 | 74 | return null; 75 | } 76 | } 77 | 78 | export const requireHandler = new RequireHandlerImpl(); 79 | -------------------------------------------------------------------------------- /src/Mobile/ScriptFolderWatcher.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'obsidian'; 2 | 3 | import { Notice } from 'obsidian'; 4 | import { invokeAsyncSafely } from 'obsidian-dev-utils/Async'; 5 | 6 | import { ScriptFolderWatcher } from '../ScriptFolderWatcher.ts'; 7 | 8 | interface ModificationEntry { 9 | isChanged: boolean; 10 | modificationTime: number; 11 | } 12 | 13 | const MILLISECONDS_IN_SECOND = 1000; 14 | 15 | class ScriptFolderWatcherImpl extends ScriptFolderWatcher { 16 | private modificationTimes = new Map(); 17 | private timeoutId: null | number = null; 18 | 19 | protected override async startWatcher(onChange: () => Promise): Promise { 20 | const invocableScriptsFolder = this.plugin.settings.getInvocableScriptsFolder(); 21 | if (!invocableScriptsFolder) { 22 | return false; 23 | } 24 | 25 | if (!(await this.plugin.app.vault.exists(invocableScriptsFolder))) { 26 | const message = `Invocable scripts folder not found: ${invocableScriptsFolder}`; 27 | new Notice(message); 28 | console.error(message); 29 | return false; 30 | } 31 | 32 | await this.watch(onChange); 33 | return true; 34 | } 35 | 36 | protected override stopWatcher(): void { 37 | this.modificationTimes.clear(); 38 | if (this.timeoutId === null) { 39 | return; 40 | } 41 | window.clearTimeout(this.timeoutId); 42 | this.timeoutId = null; 43 | } 44 | 45 | private async checkFile(app: App, file: string): Promise { 46 | const stat = await app.vault.adapter.stat(file); 47 | let modificationTime = stat?.mtime ?? 0; 48 | let isUpdated = this.modificationTimes.get(file) !== modificationTime; 49 | 50 | if (stat?.type === 'folder') { 51 | const listedFiles = await app.vault.adapter.list(file); 52 | 53 | for (const subFile of [...listedFiles.files, ...listedFiles.folders]) { 54 | const subFileModificationEntry = await this.checkFile(app, subFile); 55 | if (subFileModificationEntry.isChanged) { 56 | isUpdated = true; 57 | } 58 | if (subFileModificationEntry.modificationTime > modificationTime) { 59 | modificationTime = subFileModificationEntry.modificationTime; 60 | } 61 | } 62 | } 63 | 64 | this.modificationTimes.set(file, modificationTime); 65 | return { isChanged: isUpdated, modificationTime }; 66 | } 67 | 68 | private async watch(onChange: () => Promise): Promise { 69 | const modificationEntry = await this.checkFile(this.plugin.app, this.plugin.settings.getInvocableScriptsFolder()); 70 | if (modificationEntry.isChanged) { 71 | await onChange(); 72 | } 73 | 74 | this.timeoutId = window.setTimeout( 75 | () => { 76 | invokeAsyncSafely(() => this.watch(onChange)); 77 | }, 78 | this.plugin.settings.mobileChangesCheckingIntervalInSeconds * MILLISECONDS_IN_SECOND 79 | ); 80 | } 81 | } 82 | 83 | export const scriptFolderWatcher = new ScriptFolderWatcherImpl(); 84 | -------------------------------------------------------------------------------- /src/PathSuggest.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'obsidian'; 2 | 3 | import { AbstractInputSuggest } from 'obsidian'; 4 | import { 5 | basename, 6 | extname, 7 | relative 8 | } from 'obsidian-dev-utils/Path'; 9 | 10 | import { EXTENSIONS } from './RequireHandler.ts'; 11 | 12 | export function addPathSuggest(app: App, textInputEl: HTMLInputElement, rootFn: () => string, type: 'file' | 'folder'): PathSuggest { 13 | return new PathSuggest(app, textInputEl, rootFn, type); 14 | } 15 | 16 | const CACHE_DURATION_IN_MILLISECONDS = 30000; 17 | 18 | interface PathEntry { 19 | path: string; 20 | type: PathEntryType; 21 | } 22 | 23 | type PathEntryType = 'file' | 'folder'; 24 | 25 | class PathSuggest extends AbstractInputSuggest { 26 | private pathEntries: null | PathEntry[] = null; 27 | private refreshTimeoutId: null | number = null; 28 | public constructor(app: App, private textInputEl: HTMLInputElement, private rootFn: () => string, private type: PathEntryType) { 29 | super(app, textInputEl); 30 | } 31 | 32 | public override async getSuggestions(input: string): Promise { 33 | const entries = await this.getPathEntries(this.app); 34 | const suggestions = entries.filter((entry) => entry.path.includes(input)).sort((a, b) => a.path.localeCompare(b.path)); 35 | suggestions.unshift({ 36 | path: '', 37 | type: this.type 38 | }); 39 | return suggestions; 40 | } 41 | 42 | public refresh(): void { 43 | if (this.refreshTimeoutId) { 44 | window.clearTimeout(this.refreshTimeoutId); 45 | } 46 | this.pathEntries = null; 47 | } 48 | 49 | public override renderSuggestion(value: PathEntry, el: HTMLElement): void { 50 | el.setText(value.path || '(blank)'); 51 | } 52 | 53 | public override selectSuggestion(value: PathEntry): void { 54 | this.setValue(value.path); 55 | this.textInputEl.dispatchEvent(new Event('input')); 56 | setTimeout(() => { 57 | this.close(); 58 | this.textInputEl.blur(); 59 | }, 0); 60 | } 61 | 62 | private async fillPathEntries(app: App, path: string, type: PathEntryType): Promise { 63 | this.pathEntries ??= []; 64 | 65 | if (basename(path) === 'node_modules') { 66 | return; 67 | } 68 | 69 | let shouldAdd = type === this.type && path !== this.rootFn(); 70 | 71 | if (shouldAdd) { 72 | if (type === 'file') { 73 | const ext = extname(path); 74 | if (!EXTENSIONS.includes(ext)) { 75 | shouldAdd = false; 76 | } 77 | } 78 | } 79 | 80 | if (shouldAdd) { 81 | this.pathEntries.push({ 82 | path: relative(this.rootFn(), path), 83 | type 84 | }); 85 | } 86 | 87 | if (type === 'file') { 88 | return; 89 | } 90 | 91 | const listedFiles = await app.vault.adapter.list(path); 92 | for (const file of listedFiles.files) { 93 | await this.fillPathEntries(app, file, 'file'); 94 | } 95 | 96 | for (const folder of listedFiles.folders) { 97 | await this.fillPathEntries(app, folder, 'folder'); 98 | } 99 | } 100 | 101 | private async getPathEntries(app: App): Promise { 102 | if (!this.pathEntries) { 103 | this.pathEntries = []; 104 | await this.fillPathEntries(app, this.rootFn(), 'folder'); 105 | } 106 | 107 | this.refreshTimeoutId = window.setTimeout(this.refresh.bind(this), CACHE_DURATION_IN_MILLISECONDS); 108 | return this.pathEntries; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/PlatformDependencies.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from 'obsidian'; 2 | 3 | import type { RequireHandler } from './RequireHandler.ts'; 4 | import type { ScriptFolderWatcher } from './ScriptFolderWatcher.ts'; 5 | 6 | export interface PlatformDependencies { 7 | requireHandler: RequireHandler; 8 | scriptFolderWatcher: ScriptFolderWatcher; 9 | } 10 | 11 | export async function getPlatformDependencies(): Promise { 12 | const module = Platform.isMobile 13 | ? await import('./Mobile/Dependencies.ts') 14 | : await import('./Desktop/Dependencies.ts'); 15 | return module.platformDependencies; 16 | } 17 | -------------------------------------------------------------------------------- /src/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { PluginSettingsWrapper } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsWrapper'; 2 | 3 | import { 4 | convertAsyncToSync, 5 | invokeAsyncSafely 6 | } from 'obsidian-dev-utils/Async'; 7 | import { PluginBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginBase'; 8 | 9 | import type { PluginSettings } from './PluginSettings.ts'; 10 | import type { PluginTypes } from './PluginTypes.ts'; 11 | import type { RequireHandler } from './RequireHandler.ts'; 12 | import type { ScriptFolderWatcher } from './ScriptFolderWatcher.ts'; 13 | 14 | import { 15 | registerCodeButtonBlock, 16 | unloadTempPlugins 17 | } from './CodeButtonBlock.ts'; 18 | import { getPlatformDependencies } from './PlatformDependencies.ts'; 19 | import { PluginSettingsManager } from './PluginSettingsManager.ts'; 20 | import { PluginSettingsTab } from './PluginSettingsTab.ts'; 21 | import { ProtocolHandlerComponent } from './ProtocolHandlerComponent.ts'; 22 | import { 23 | cleanupStartupScript, 24 | invokeStartupScript, 25 | registerInvocableScripts, 26 | reloadStartupScript, 27 | selectAndInvokeScript 28 | } from './Script.ts'; 29 | 30 | export class Plugin extends PluginBase { 31 | private requireHandler!: RequireHandler; 32 | private scriptFolderWatcher!: ScriptFolderWatcher; 33 | 34 | public async applyNewSettings(): Promise { 35 | await this.scriptFolderWatcher.register(this, () => registerInvocableScripts(this)); 36 | } 37 | 38 | public override async onLoadSettings(settings: PluginSettingsWrapper, isInitialLoad: boolean): Promise { 39 | await super.onLoadSettings(settings, isInitialLoad); 40 | invokeAsyncSafely(async () => { 41 | await this.waitForLifecycleEvent('layoutReady'); 42 | await this.applyNewSettings(); 43 | }); 44 | } 45 | 46 | public override async onSaveSettings( 47 | newSettings: PluginSettingsWrapper, 48 | oldSettings: PluginSettingsWrapper, 49 | context?: unknown 50 | ): Promise { 51 | await super.onSaveSettings(newSettings, oldSettings, context); 52 | await this.applyNewSettings(); 53 | } 54 | 55 | protected override createSettingsManager(): PluginSettingsManager { 56 | return new PluginSettingsManager(this); 57 | } 58 | 59 | protected override createSettingsTab(): null | PluginSettingsTab { 60 | return new PluginSettingsTab(this); 61 | } 62 | 63 | protected override async onLayoutReady(): Promise { 64 | await invokeStartupScript(this); 65 | this.register(() => cleanupStartupScript(this)); 66 | } 67 | 68 | protected override async onloadImpl(): Promise { 69 | await super.onloadImpl(); 70 | const platformDependencies = await getPlatformDependencies(); 71 | this.scriptFolderWatcher = platformDependencies.scriptFolderWatcher; 72 | this.requireHandler = platformDependencies.requireHandler; 73 | this.requireHandler.register(this, require); 74 | 75 | registerCodeButtonBlock(this); 76 | this.addCommand({ 77 | callback: () => selectAndInvokeScript(this), 78 | id: 'invokeScript', 79 | name: 'Invoke Script: <>' 80 | }); 81 | 82 | this.addCommand({ 83 | callback: () => { 84 | this.requireHandler.clearCache(); 85 | }, 86 | id: 'clearCache', 87 | name: 'Clear Cache' 88 | }); 89 | 90 | this.addCommand({ 91 | callback: unloadTempPlugins, 92 | id: 'unload-temp-plugins', 93 | name: 'Unload Temp Plugins' 94 | }); 95 | 96 | this.addCommand({ 97 | callback: convertAsyncToSync(() => reloadStartupScript(this)), 98 | id: 'reload-startup-script', 99 | name: 'Reload Startup Script' 100 | }); 101 | 102 | this.addChild(new ProtocolHandlerComponent(this)); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/PluginSettings.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'obsidian-dev-utils/Path'; 2 | 3 | export class PluginSettings { 4 | public invocableScriptsFolder = ''; 5 | // eslint-disable-next-line no-magic-numbers 6 | public mobileChangesCheckingIntervalInSeconds = 30; 7 | public modulesRoot = ''; 8 | public shouldHandleProtocolUrls = false; 9 | public shouldUseSyncFallback = false; 10 | public startupScriptPath = ''; 11 | 12 | public getInvocableScriptsFolder(): string { 13 | return this.getPathRelativeToModulesRoot(this.invocableScriptsFolder); 14 | } 15 | 16 | public getStartupScriptPath(): string { 17 | return this.getPathRelativeToModulesRoot(this.startupScriptPath); 18 | } 19 | 20 | private getPathRelativeToModulesRoot(path: string): string { 21 | if (!path) { 22 | return ''; 23 | } 24 | 25 | if (!this.modulesRoot) { 26 | return path; 27 | } 28 | 29 | return join(this.modulesRoot, path); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/PluginSettingsManager.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'obsidian'; 2 | import type { MaybeReturn } from 'obsidian-dev-utils/Type'; 3 | 4 | import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsManagerBase'; 5 | import { 6 | extname, 7 | join 8 | } from 'obsidian-dev-utils/Path'; 9 | 10 | import type { PluginTypes } from './PluginTypes.ts'; 11 | 12 | import { PluginSettings } from './PluginSettings.ts'; 13 | import { EXTENSIONS } from './RequireHandler.ts'; 14 | 15 | interface LegacySettings { 16 | invocableScriptsDirectory: string; 17 | } 18 | 19 | export class PluginSettingsManager extends PluginSettingsManagerBase { 20 | protected override createDefaultSettings(): PluginSettings { 21 | return new PluginSettings(); 22 | } 23 | 24 | protected override async onLoadRecord(record: Record): Promise { 25 | await super.onLoadRecord(record); 26 | const legacySettings = record as Partial & Partial; 27 | if (legacySettings.invocableScriptsDirectory) { 28 | legacySettings.invocableScriptsFolder = legacySettings.invocableScriptsDirectory; 29 | delete legacySettings.invocableScriptsDirectory; 30 | } 31 | } 32 | 33 | protected override registerValidators(): void { 34 | this.registerValidator('modulesRoot', async (value): Promise> => { 35 | if (!value) { 36 | return; 37 | } 38 | 39 | return await validatePath(this.app, value, 'folder'); 40 | }); 41 | 42 | this.registerValidator('invocableScriptsFolder', async (value, settings): Promise> => { 43 | if (!value) { 44 | return; 45 | } 46 | 47 | const path = join(settings.modulesRoot, value); 48 | return await validatePath(this.plugin.app, path, 'folder'); 49 | }); 50 | 51 | this.registerValidator('startupScriptPath', async (value, settings): Promise> => { 52 | if (!value) { 53 | return; 54 | } 55 | 56 | const path = join(settings.modulesRoot, value); 57 | const ans = await validatePath(this.plugin.app, path, 'file'); 58 | if (ans) { 59 | return ans; 60 | } 61 | 62 | const ext = extname(path); 63 | if (!EXTENSIONS.includes(ext)) { 64 | return `Only the following extensions are supported: ${EXTENSIONS.join(', ')}`; 65 | } 66 | }); 67 | } 68 | } 69 | 70 | async function validatePath(app: App, path: string, type: 'file' | 'folder'): Promise> { 71 | if (!await app.vault.exists(path)) { 72 | return 'Path does not exist'; 73 | } 74 | 75 | const stat = await app.vault.adapter.stat(path); 76 | if (stat?.type !== type) { 77 | return `Path is not a ${type}`; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/PluginSettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { Events } from 'obsidian'; 2 | import { appendCodeBlock } from 'obsidian-dev-utils/HTMLElement'; 3 | import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsTabBase'; 4 | import { SettingEx } from 'obsidian-dev-utils/obsidian/SettingEx'; 5 | 6 | import type { PluginTypes } from './PluginTypes.ts'; 7 | 8 | import { addPathSuggest } from './PathSuggest.ts'; 9 | 10 | export class PluginSettingsTab extends PluginSettingsTabBase { 11 | public override display(): void { 12 | super.display(); 13 | this.containerEl.empty(); 14 | const events = new Events(); 15 | 16 | new SettingEx(this.containerEl) 17 | .setName('Script modules root') 18 | .setDesc(createFragment((f) => { 19 | f.appendText('Path to the folder that is considered as '); 20 | appendCodeBlock(f, '/'); 21 | f.appendText(' in '); 22 | appendCodeBlock(f, 'require("/script.js")'); 23 | f.createEl('br'); 24 | f.appendText('Leave blank to use the root of the vault.'); 25 | })) 26 | .addText((text) => { 27 | this.bind(text, 'modulesRoot', { 28 | onChanged: () => { 29 | events.trigger('modulesRootChanged'); 30 | } 31 | }) 32 | .setPlaceholder('path/to/script/modules/root'); 33 | 34 | addPathSuggest(this.plugin.app, text.inputEl, () => '', 'folder'); 35 | }); 36 | 37 | new SettingEx(this.containerEl) 38 | .setName('Invocable scripts folder') 39 | .setDesc(createFragment((f) => { 40 | f.appendText('Path to the folder with invocable scripts.'); 41 | f.createEl('br'); 42 | f.appendText('Should be a relative path to the '); 43 | appendCodeBlock(f, 'Script modules root'); 44 | f.createEl('br'); 45 | f.appendText('Leave blank if you don\'t use invocable scripts.'); 46 | })) 47 | .addText((text) => { 48 | this.bind(text, 'invocableScriptsFolder') 49 | .setPlaceholder('path/to/invocable/scripts/folder'); 50 | 51 | const suggest = addPathSuggest(this.plugin.app, text.inputEl, () => this.plugin.settings.modulesRoot, 'folder'); 52 | 53 | events.on('modulesRootChanged', () => { 54 | text.onChanged(); 55 | suggest.refresh(); 56 | }); 57 | }); 58 | 59 | new SettingEx(this.containerEl) 60 | .setName('Startup script path') 61 | .setDesc(createFragment((f) => { 62 | f.appendText('Path to the invocable script executed on startup.'); 63 | f.createEl('br'); 64 | f.appendText('Should be a relative path to the '); 65 | appendCodeBlock(f, 'Script modules root'); 66 | f.createEl('br'); 67 | f.appendText('Leave blank if you don\'t use startup script.'); 68 | })) 69 | .addText((text) => { 70 | this.bind(text, 'startupScriptPath') 71 | .setPlaceholder('path/to/startup.ts'); 72 | const suggest = addPathSuggest(this.plugin.app, text.inputEl, () => this.plugin.settings.modulesRoot, 'file'); 73 | 74 | events.on('modulesRootChanged', () => { 75 | text.onChanged(); 76 | suggest.refresh(); 77 | }); 78 | }); 79 | 80 | new SettingEx(this.containerEl) 81 | .setName('Hotkeys') 82 | .setDesc('Hotkeys to invoke scripts.') 83 | .addButton((button) => 84 | button 85 | .setButtonText('Configure') 86 | .setTooltip('Configure Hotkeys') 87 | .onClick(() => { 88 | const hotkeysTab = this.app.setting.openTabById('hotkeys'); 89 | hotkeysTab.searchComponent.setValue(`${this.plugin.manifest.name}:`); 90 | hotkeysTab.updateHotkeyVisibility(); 91 | }) 92 | ); 93 | 94 | new SettingEx(this.containerEl) 95 | .setName('Mobile: Changes checking interval') 96 | .setDesc(createFragment((f) => { 97 | f.appendText('Interval in seconds to check for changes in the invocable scripts folder '); 98 | f.createEl('strong', { text: '(only on mobile)' }); 99 | f.appendText('.'); 100 | })) 101 | .addNumber((text) => { 102 | this.bind(text, 'mobileChangesCheckingIntervalInSeconds') 103 | .setMin(1); 104 | }); 105 | 106 | new SettingEx(this.containerEl) 107 | .setName('Desktop: Synchronous fallback') 108 | .setDesc(createFragment((f) => { 109 | f.appendText('Whether to use a synchronous '); 110 | appendCodeBlock(f, 'require()'); 111 | f.appendText('fallback if '); 112 | appendCodeBlock(f, 'requireAsync()'); 113 | f.appendText(' failed '); 114 | f.createEl('strong', { text: '(only on desktop)' }); 115 | f.appendText('.'); 116 | })) 117 | .addToggle((toggle) => { 118 | this.bind(toggle, 'shouldUseSyncFallback'); 119 | }); 120 | 121 | new SettingEx(this.containerEl) 122 | .setName('Handle protocol URLs') 123 | .setDesc(createFragment((f) => { 124 | f.appendText('Whether to handle protocol URLs: '); 125 | appendCodeBlock(f, 'obsidian://CodeScriptToolkit?...'); 126 | f.createEl('br'); 127 | f.appendText('⚠️ WARNING: This allows arbitrary code execution, which could pose a security risk. Use with caution.'); 128 | })) 129 | .addToggle((toggle) => { 130 | this.bind(toggle, 'shouldHandleProtocolUrls'); 131 | }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/PluginTypes.ts: -------------------------------------------------------------------------------- 1 | import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginTypesBase'; 2 | 3 | import type { Plugin } from './Plugin.ts'; 4 | import type { PluginSettings } from './PluginSettings.ts'; 5 | import type { PluginSettingsManager } from './PluginSettingsManager.ts'; 6 | import type { PluginSettingsTab } from './PluginSettingsTab.ts'; 7 | 8 | export interface PluginTypes extends PluginTypesBase { 9 | plugin: Plugin; 10 | pluginSettings: PluginSettings; 11 | pluginSettingsManager: PluginSettingsManager; 12 | pluginSettingsTab: PluginSettingsTab; 13 | } 14 | -------------------------------------------------------------------------------- /src/ProtocolHandlerComponent.ts: -------------------------------------------------------------------------------- 1 | import type { ObsidianProtocolData } from 'obsidian'; 2 | 3 | import { Component } from 'obsidian'; 4 | import { convertAsyncToSync } from 'obsidian-dev-utils/Async'; 5 | 6 | import type { Plugin } from './Plugin.ts'; 7 | 8 | import { requireStringAsync } from './RequireHandlerUtils.ts'; 9 | 10 | const PROTOCOL_HANDLER_ACTION = 'CodeScriptToolkit'; 11 | 12 | type GenericAsyncFn = (...args: unknown[]) => Promise; 13 | 14 | interface Query { 15 | args?: string; 16 | code?: string; 17 | functionName?: string; 18 | module?: string; 19 | } 20 | 21 | interface WindowWithRequireAsync { 22 | requireAsync: (id: string) => Promise>; 23 | } 24 | 25 | export class ProtocolHandlerComponent extends Component { 26 | public constructor(private readonly plugin: Plugin) { 27 | super(); 28 | } 29 | 30 | public override onload(): void { 31 | this.plugin.registerObsidianProtocolHandler(PROTOCOL_HANDLER_ACTION, convertAsyncToSync(this.processQuery.bind(this))); 32 | } 33 | 34 | private async processQuery(query: ObsidianProtocolData): Promise { 35 | if (!this.plugin.settings.shouldHandleProtocolUrls) { 36 | console.warn('Handling of protocol URLs is disabled in plugin settings.'); 37 | return; 38 | } 39 | 40 | const parsedQuery = query as Partial; 41 | 42 | if (!parsedQuery.module && !parsedQuery.code) { 43 | throw new Error('URL provided neither module nor code parameters.'); 44 | } 45 | 46 | if (parsedQuery.module && parsedQuery.code) { 47 | throw new Error('URL provided both module and code parameters.'); 48 | } 49 | 50 | if (parsedQuery.module) { 51 | parsedQuery.functionName ??= 'invoke'; 52 | parsedQuery.args ??= parsedQuery.functionName === 'invoke' ? 'app' : ''; 53 | 54 | this.plugin.consoleDebug('Invoking script file from URL action:', { 55 | args: parsedQuery.args, 56 | functionName: parsedQuery.functionName, 57 | module: parsedQuery.module 58 | }); 59 | 60 | parsedQuery.code = `(${invokeModuleFn.toString()})('${parsedQuery.module}', '${parsedQuery.functionName}', [${parsedQuery.args}])`; 61 | } else { 62 | parsedQuery.code ??= ''; 63 | 64 | this.plugin.consoleDebug('Invoking code from URL action:', { 65 | code: parsedQuery.code 66 | }); 67 | } 68 | 69 | await requireStringAsync(parsedQuery.code, 'dynamic-script-from-url-handler.ts'); 70 | } 71 | } 72 | 73 | async function invokeModuleFn(moduleSpecifier: string, functionName: string, args: unknown[]): Promise { 74 | const windowWithRequireAsync = window as unknown as WindowWithRequireAsync; 75 | const module = await windowWithRequireAsync.requireAsync(moduleSpecifier); 76 | const fn = module[functionName]; 77 | if (typeof fn === 'undefined') { 78 | throw new Error(`Function ${functionName} in module ${moduleSpecifier} is not defined.`); 79 | } 80 | if (typeof fn !== 'function') { 81 | throw new Error(`${functionName} in module ${moduleSpecifier} is not a function.`); 82 | } 83 | await (fn as GenericAsyncFn)(...args); 84 | } 85 | -------------------------------------------------------------------------------- /src/RequireHandler.ts: -------------------------------------------------------------------------------- 1 | import type { PackageJson } from 'obsidian-dev-utils/ScriptUtils/Npm'; 2 | import type { Promisable } from 'type-fest'; 3 | 4 | import { debuggableEval } from 'debuggable-eval'; 5 | import { 6 | Platform, 7 | requestUrl 8 | } from 'obsidian'; 9 | import { noop } from 'obsidian-dev-utils/Function'; 10 | import { normalizeOptionalProperties } from 'obsidian-dev-utils/Object'; 11 | import { 12 | basename, 13 | dirname, 14 | extname, 15 | isAbsolute, 16 | join, 17 | toPosixPath 18 | } from 'obsidian-dev-utils/Path'; 19 | import { 20 | replaceAll, 21 | trimEnd, 22 | trimStart 23 | } from 'obsidian-dev-utils/String'; 24 | import { isUrl } from 'obsidian-dev-utils/url'; 25 | 26 | import type { Plugin } from './Plugin.ts'; 27 | 28 | import { SequentialBabelPlugin } from './babel/CombineBabelPlugins.ts'; 29 | import { ConvertToCommonJsBabelPlugin } from './babel/ConvertToCommonJsBabelPlugin.ts'; 30 | import { ExtractRequireArgsListBabelPlugin } from './babel/ExtractRequireArgsListBabelPlugin.ts'; 31 | import { FixSourceMapBabelPlugin } from './babel/FixSourceMapBabelPlugin.ts'; 32 | import { WrapInRequireFunctionBabelPlugin } from './babel/WrapInRequireFunctionBabelPlugin.ts'; 33 | import { builtInModuleNames } from './BuiltInModuleNames.ts'; 34 | import { 35 | CachedModuleProxyHandler, 36 | EMPTY_MODULE_SYMBOL 37 | } from './CachedModuleProxyHandler.ts'; 38 | import { CacheInvalidationMode } from './CacheInvalidationMode.ts'; 39 | 40 | export enum ResolvedType { 41 | Module = 'module', 42 | Path = 'path', 43 | Url = 'url' 44 | } 45 | 46 | export type ModuleType = 'json' | 'jsTs' | 'node' | 'wasm'; 47 | export type PluginRequireFn = (id: string) => unknown; 48 | export type RequireAsyncWrapperFn = (requireFn: RequireAsyncWrapperArg) => Promise; 49 | export type RequireFn = (id: string, options?: Partial) => unknown; 50 | 51 | export interface RequireOptions { 52 | cacheInvalidationMode: CacheInvalidationMode; 53 | moduleType?: ModuleType; 54 | parentPath?: string; 55 | } 56 | 57 | interface EmptyModule { 58 | [EMPTY_MODULE_SYMBOL]: boolean; 59 | } 60 | 61 | type ModuleFnWrapper = ( 62 | require: NodeJS.Require, 63 | module: { exports: unknown }, 64 | exports: unknown, 65 | requireAsyncWrapper: RequireAsyncWrapperFn 66 | ) => Promisable; 67 | type RequireAsyncFn = (id: string, options?: Partial) => Promise; 68 | type RequireAsyncWrapperArg = (require: RequireExFn) => Promisable; 69 | type RequireExFn = { parentPath?: string } & NodeJS.Require & RequireFn; 70 | 71 | interface RequireWindow { 72 | require?: RequireExFn; 73 | requireAsync?: RequireAsyncFn; 74 | requireAsyncWrapper?: RequireAsyncWrapperFn; 75 | } 76 | 77 | interface ResolveResult { 78 | resolvedId: string; 79 | resolvedType: ResolvedType; 80 | } 81 | 82 | interface SplitQueryResult { 83 | cleanStr: string; 84 | query: string; 85 | } 86 | 87 | interface WrapRequireOptions { 88 | beforeRequire?(id: string): void; 89 | optionsToAppend?: Partial; 90 | optionsToPrepend?: Partial; 91 | require: RequireExFn; 92 | } 93 | 94 | export const ENTRY_POINT = '.'; 95 | export const EXTENSIONS = ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']; 96 | export const MODULE_NAME_SEPARATOR = '*'; 97 | export const MODULE_TO_SKIP = Symbol('MODULE_TO_SKIP'); 98 | export const NODE_MODULES_FOLDER = 'node_modules'; 99 | const PACKAGE_JSON = 'package.json'; 100 | export const PATH_SUFFIXES = ['', ...EXTENSIONS, ...EXTENSIONS.map((ext) => `/index${ext}`)]; 101 | export const PRIVATE_MODULE_PREFIX = '#'; 102 | export const RELATIVE_MODULE_PATH_SEPARATOR = '/'; 103 | export const SCOPED_MODULE_PREFIX = '@'; 104 | const WILDCARD_MODULE_CONDITION_SUFFIX = '/*'; 105 | export const VAULT_ROOT_PREFIX = '//'; 106 | 107 | interface RequireStringImplOptions { 108 | code: string; 109 | evalPrefix: string; 110 | path: string; 111 | shouldWrapInAsyncFunction: boolean; 112 | urlSuffix: string; 113 | } 114 | 115 | interface RequireStringImplResult { 116 | exportsFn: () => unknown; 117 | promisable: Promisable; 118 | } 119 | 120 | export abstract class RequireHandler { 121 | protected readonly currentModulesTimestampChain = new Set(); 122 | protected readonly moduleDependencies = new Map>(); 123 | protected modulesCache!: NodeJS.Dict; 124 | protected readonly moduleTimestamps = new Map(); 125 | protected plugin!: Plugin; 126 | protected requireEx!: RequireExFn; 127 | protected vaultAbsolutePath!: string; 128 | private originalRequire!: NodeJS.Require; 129 | private pluginRequire!: PluginRequireFn; 130 | 131 | public clearCache(): void { 132 | this.moduleTimestamps.clear(); 133 | this.currentModulesTimestampChain.clear(); 134 | this.moduleDependencies.clear(); 135 | 136 | for (const key of Object.keys(this.modulesCache)) { 137 | if (key.startsWith('electron') || key.includes('app.asar')) { 138 | continue; 139 | } 140 | 141 | this.deleteCacheEntry(key); 142 | } 143 | } 144 | 145 | public register(plugin: Plugin, pluginRequire: PluginRequireFn): void { 146 | this.plugin = plugin; 147 | this.pluginRequire = pluginRequire; 148 | this.vaultAbsolutePath = toPosixPath(plugin.app.vault.adapter.basePath); 149 | this.originalRequire = window.require; 150 | 151 | this.requireEx = Object.assign(this.require.bind(this), { 152 | cache: {} 153 | }, this.originalRequire) as RequireExFn; 154 | this.modulesCache = this.requireEx.cache; 155 | 156 | const requireWindow = window as Partial; 157 | 158 | requireWindow.require = this.requireEx; 159 | plugin.register(() => { 160 | requireWindow.require = this.originalRequire; 161 | }); 162 | 163 | requireWindow.requireAsync = this.requireAsync.bind(this); 164 | plugin.register(() => delete requireWindow.requireAsync); 165 | 166 | requireWindow.requireAsyncWrapper = this.requireAsyncWrapper.bind(this); 167 | plugin.register(() => delete requireWindow.requireAsyncWrapper); 168 | } 169 | 170 | public async requireAsync(id: string, options?: Partial): Promise { 171 | const DEFAULT_OPTIONS: RequireOptions = { 172 | cacheInvalidationMode: CacheInvalidationMode.WhenPossible 173 | }; 174 | const fullOptions = { 175 | ...DEFAULT_OPTIONS, 176 | ...options 177 | }; 178 | const cleanId = splitQuery(id).cleanStr; 179 | const specialModule = this.requireSpecialModule(cleanId); 180 | if (specialModule) { 181 | if (specialModule === MODULE_TO_SKIP) { 182 | return null; 183 | } 184 | return specialModule; 185 | } 186 | 187 | const { resolvedId, resolvedType } = this.resolve(id, fullOptions.parentPath); 188 | 189 | let cleanResolvedId: string; 190 | let query: string; 191 | 192 | if (resolvedType === ResolvedType.Url) { 193 | cleanResolvedId = resolvedId; 194 | query = ''; 195 | } else { 196 | ({ cleanStr: cleanResolvedId, query } = splitQuery(resolvedId)); 197 | } 198 | 199 | const RELOAD_TIMEOUT_IN_MILLISECONDS = 2000; 200 | const REPEAT_INTERVAL_IN_MILLISECONDS = 100; 201 | let cachedModuleEntry: NodeJS.Module | undefined = undefined; 202 | const start = performance.now(); 203 | while (performance.now() - start < RELOAD_TIMEOUT_IN_MILLISECONDS) { 204 | cachedModuleEntry = this.modulesCache[resolvedId]; 205 | if (!cachedModuleEntry || cachedModuleEntry.loaded) { 206 | break; 207 | } 208 | await sleep(REPEAT_INTERVAL_IN_MILLISECONDS); 209 | } 210 | 211 | if (cachedModuleEntry) { 212 | if (!cachedModuleEntry.loaded) { 213 | console.warn(`Circular dependency detected: ${resolvedId} -> ... -> ${fullOptions.parentPath ?? ''} -> ${resolvedId}`); 214 | return cachedModuleEntry.exports; 215 | } 216 | 217 | if (this.currentModulesTimestampChain.has(resolvedId)) { 218 | return cachedModuleEntry.exports; 219 | } 220 | 221 | switch (fullOptions.cacheInvalidationMode) { 222 | case CacheInvalidationMode.Never: 223 | return cachedModuleEntry.exports; 224 | case CacheInvalidationMode.WhenPossible: 225 | if (query) { 226 | return cachedModuleEntry.exports; 227 | } 228 | break; 229 | default: 230 | throw new Error('Unknown cacheInvalidationMode'); 231 | } 232 | } 233 | 234 | const module = await this.initModuleAndAddToCacheAsync( 235 | cleanResolvedId, 236 | () => this.requireNonCachedAsync(cleanResolvedId, resolvedType, fullOptions.cacheInvalidationMode, fullOptions.moduleType) 237 | ); 238 | if (resolvedId !== cleanResolvedId) { 239 | this.initModuleAndAddToCache(resolvedId, () => module); 240 | } 241 | return module; 242 | } 243 | 244 | public async requireStringAsync(code: string, path: string, urlSuffix?: string): Promise { 245 | urlSuffix = urlSuffix ? `/${urlSuffix}` : ''; 246 | 247 | try { 248 | return await this.initModuleAndAddToCacheAsync(path, async () => { 249 | const result = this.requireStringImpl({ 250 | code, 251 | evalPrefix: 'requireStringAsync', 252 | path, 253 | shouldWrapInAsyncFunction: true, 254 | urlSuffix 255 | }); 256 | await result.promisable; 257 | return result.exportsFn(); 258 | }); 259 | } catch (e) { 260 | throw new Error(`Failed to load module: ${path}`, { cause: e }); 261 | } 262 | } 263 | 264 | protected abstract canRequireNonCached(type: ResolvedType): boolean; 265 | 266 | protected abstract existsFileAsync(path: string): Promise; 267 | 268 | protected abstract existsFolderAsync(path: string): Promise; 269 | 270 | protected getCachedModule(id: string): unknown { 271 | return this.modulesCache[id]?.loaded ? this.modulesCache[id].exports : null; 272 | } 273 | 274 | protected getPackageJsonPath(packageFolder: string): string { 275 | return join(packageFolder, PACKAGE_JSON); 276 | } 277 | 278 | protected getRelativeModulePaths(packageJson: PackageJson, relativeModuleName: string): string[] { 279 | const isPrivateModule = relativeModuleName.startsWith(PRIVATE_MODULE_PREFIX); 280 | const importsExportsNode = isPrivateModule ? packageJson.imports : packageJson.exports; 281 | const paths = this.getExportsRelativeModulePaths(importsExportsNode, relativeModuleName); 282 | 283 | if (relativeModuleName === ENTRY_POINT) { 284 | paths.push(packageJson.main ?? ENTRY_POINT); 285 | } 286 | 287 | if (!importsExportsNode && !isPrivateModule) { 288 | paths.push(relativeModuleName); 289 | } 290 | 291 | return paths; 292 | } 293 | 294 | protected getRequireAsyncAdvice(isNewSentence?: boolean): string { 295 | let advice = `consider using 296 | 297 | const module = await requireAsync(id); 298 | 299 | or 300 | 301 | await requireAsyncWrapper((require) => { 302 | const module = require(id); 303 | });`; 304 | 305 | if (isNewSentence) { 306 | advice = advice.charAt(0).toUpperCase() + advice.slice(1); 307 | } 308 | 309 | return advice; 310 | } 311 | 312 | protected abstract getTimestampAsync(path: string): Promise; 313 | 314 | protected handleCodeWithTopLevelAwait(_path: string): void { 315 | noop(); 316 | } 317 | 318 | protected initModuleAndAddToCache(id: string, moduleInitializer: () => unknown): unknown { 319 | if (!this.modulesCache[id] || this.modulesCache[id].loaded) { 320 | this.deleteCacheEntry(id); 321 | this.addToModuleCache(id, this.createEmptyModule(id), false); 322 | } 323 | try { 324 | const module = moduleInitializer(); 325 | const cachedModule = this.getCachedModule(id); 326 | if (cachedModule) { 327 | return cachedModule; 328 | } 329 | if (!this.isEmptyModule(module)) { 330 | this.addToModuleCache(id, module); 331 | } 332 | 333 | return module; 334 | } catch (e) { 335 | this.deleteCacheEntry(id); 336 | throw e; 337 | } 338 | } 339 | 340 | protected async initModuleAndAddToCacheAsync(id: string, moduleInitializer: () => Promise): Promise { 341 | if (!this.modulesCache[id] || this.modulesCache[id].loaded) { 342 | this.deleteCacheEntry(id); 343 | this.addToModuleCache(id, this.createEmptyModule(id), false); 344 | } 345 | try { 346 | const module = await moduleInitializer(); 347 | const cachedModule = this.getCachedModule(id); 348 | if (cachedModule) { 349 | return cachedModule; 350 | } 351 | if (!this.isEmptyModule(module)) { 352 | this.addToModuleCache(id, module); 353 | } 354 | return module; 355 | } catch (e) { 356 | this.deleteCacheEntry(id); 357 | throw e; 358 | } 359 | } 360 | 361 | protected isJson(path: string): boolean { 362 | return splitQuery(path).cleanStr.endsWith('.json'); 363 | } 364 | 365 | protected makeChildRequire(parentPath: string): RequireExFn { 366 | return this.wrapRequire({ 367 | beforeRequire: (id: string): void => { 368 | let dependencies = this.moduleDependencies.get(parentPath); 369 | if (!dependencies) { 370 | dependencies = new Set(); 371 | this.moduleDependencies.set(parentPath, dependencies); 372 | } 373 | dependencies.add(id); 374 | }, 375 | optionsToPrepend: { parentPath }, 376 | require: this.requireEx 377 | }); 378 | } 379 | 380 | protected abstract readFileAsync(path: string): Promise; 381 | protected abstract readFileBinaryAsync(path: string): Promise; 382 | 383 | protected async requireAsyncWrapper(requireFn: (require: RequireExFn) => Promisable, require?: RequireExFn): Promise { 384 | const result = new ExtractRequireArgsListBabelPlugin().transform(requireFn.toString(), 'extract-requires.js'); 385 | const requireArgsList = result.data.requireArgsList; 386 | for (const requireArgs of requireArgsList) { 387 | const { id, options } = requireArgs; 388 | const newOptions = normalizeOptionalProperties>({ parentPath: require?.parentPath, ...options }); 389 | await this.requireAsync(id, newOptions); 390 | } 391 | return await requireFn(this.wrapRequire({ 392 | optionsToAppend: { cacheInvalidationMode: CacheInvalidationMode.Never }, 393 | require: require ?? this.requireEx 394 | })); 395 | } 396 | 397 | protected abstract requireNodeBinaryAsync(path: string, arrayBuffer?: ArrayBuffer): Promise; 398 | 399 | protected abstract requireNonCached(id: string, type: ResolvedType, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): unknown; 400 | 401 | protected requireSpecialModule(id: string): unknown { 402 | if (id === 'obsidian/app') { 403 | return this.plugin.app; 404 | } 405 | 406 | if (id === 'obsidian/builtInModuleNames') { 407 | return builtInModuleNames; 408 | } 409 | 410 | if (builtInModuleNames.includes(id)) { 411 | return this.pluginRequire(id); 412 | } 413 | 414 | return null; 415 | } 416 | 417 | protected requireStringImpl(options: RequireStringImplOptions): RequireStringImplResult { 418 | const folder = isUrl(options.path) ? '' : dirname(options.path); 419 | const filename = isUrl(options.path) ? options.path : basename(options.path); 420 | const url = convertPathToObsidianUrl(options.path) + options.urlSuffix; 421 | 422 | const transformResult = new SequentialBabelPlugin([ 423 | new ConvertToCommonJsBabelPlugin(), 424 | new WrapInRequireFunctionBabelPlugin(options.shouldWrapInAsyncFunction), 425 | new FixSourceMapBabelPlugin(url) 426 | ]).transform(options.code, filename, folder); 427 | 428 | if (transformResult.error) { 429 | throw new Error(`Failed to transform code from: ${options.path}`, { cause: transformResult.error }); 430 | } 431 | 432 | if (transformResult.data.hasTopLevelAwait) { 433 | this.handleCodeWithTopLevelAwait(options.path); 434 | } 435 | 436 | const moduleFnWrapper = debuggableEval(transformResult.transformedCode, `${options.evalPrefix}/${options.path}${options.urlSuffix}`) as ModuleFnWrapper; 437 | const module = { exports: {} }; 438 | const childRequire = this.makeChildRequire(options.path); 439 | // eslint-disable-next-line import-x/no-commonjs 440 | const promisable = moduleFnWrapper(childRequire, module, module.exports, this.requireAsyncWrapper.bind(this)); 441 | return { 442 | // eslint-disable-next-line import-x/no-commonjs 443 | exportsFn: () => module.exports, 444 | promisable 445 | }; 446 | } 447 | 448 | protected resolve(id: string, parentPath?: string): ResolveResult { 449 | id = toPosixPath(id); 450 | 451 | if (isUrl(id)) { 452 | const FILE_URL_PREFIX = 'file:///'; 453 | if (id.toLowerCase().startsWith(FILE_URL_PREFIX)) { 454 | return { resolvedId: id.slice(FILE_URL_PREFIX.length), resolvedType: ResolvedType.Path }; 455 | } 456 | 457 | if (id.toLowerCase().startsWith(Platform.resourcePathPrefix)) { 458 | return { resolvedId: id.slice(Platform.resourcePathPrefix.length), resolvedType: ResolvedType.Path }; 459 | } 460 | 461 | return { resolvedId: id, resolvedType: ResolvedType.Url }; 462 | } 463 | 464 | if (id.startsWith(VAULT_ROOT_PREFIX)) { 465 | return { resolvedId: join(this.vaultAbsolutePath, trimStart(id, VAULT_ROOT_PREFIX)), resolvedType: ResolvedType.Path }; 466 | } 467 | 468 | const SYSTEM_ROOT_PATH_PREFIX = '~/'; 469 | if (id.startsWith(SYSTEM_ROOT_PATH_PREFIX)) { 470 | return { resolvedId: `/${trimStart(id, SYSTEM_ROOT_PATH_PREFIX)}`, resolvedType: ResolvedType.Path }; 471 | } 472 | 473 | const MODULES_ROOT_PATH_PREFIX = '/'; 474 | if (id.startsWith(MODULES_ROOT_PATH_PREFIX)) { 475 | return { 476 | resolvedId: join(this.vaultAbsolutePath, this.plugin.settings.modulesRoot, trimStart(id, MODULES_ROOT_PATH_PREFIX)), 477 | resolvedType: ResolvedType.Path 478 | }; 479 | } 480 | 481 | if (isAbsolute(id)) { 482 | return { resolvedId: id, resolvedType: ResolvedType.Path }; 483 | } 484 | 485 | parentPath = parentPath ? toPosixPath(parentPath) : this.getParentPathFromCallStack() ?? this.plugin.app.workspace.getActiveFile()?.path ?? 'fakeRoot.js'; 486 | if (!isAbsolute(parentPath)) { 487 | parentPath = join(this.vaultAbsolutePath, parentPath); 488 | } 489 | const parentFolder = dirname(parentPath); 490 | 491 | if (id.startsWith('./') || id.startsWith('../')) { 492 | return { resolvedId: join(parentFolder, id), resolvedType: ResolvedType.Path }; 493 | } 494 | 495 | return { resolvedId: `${parentFolder}${MODULE_NAME_SEPARATOR}${id}`, resolvedType: ResolvedType.Module }; 496 | } 497 | 498 | private addToModuleCache(id: string, module: unknown, isLoaded = true): NodeJS.Module { 499 | this.modulesCache[id] = { 500 | children: [], 501 | exports: module, 502 | filename: '', 503 | id, 504 | isPreloading: false, 505 | loaded: isLoaded, 506 | parent: null, 507 | path: '', 508 | paths: [], 509 | require: this.requireEx 510 | }; 511 | return this.modulesCache[id]; 512 | } 513 | 514 | private applyCondition(condition: string, exportsNodeChild: PackageJson.Exports, relativeModuleName: string): string[] { 515 | if (condition === 'types') { 516 | return []; 517 | } 518 | 519 | if (condition === relativeModuleName) { 520 | return this.getExportsRelativeModulePaths(exportsNodeChild, ENTRY_POINT); 521 | } 522 | 523 | if (!condition.startsWith(ENTRY_POINT)) { 524 | return this.getExportsRelativeModulePaths(exportsNodeChild, relativeModuleName); 525 | } 526 | 527 | if (condition.endsWith(WILDCARD_MODULE_CONDITION_SUFFIX)) { 528 | const parentCondition = trimEnd(condition, WILDCARD_MODULE_CONDITION_SUFFIX); 529 | const separatorIndex = relativeModuleName.lastIndexOf(RELATIVE_MODULE_PATH_SEPARATOR); 530 | const parentRelativeModuleName = separatorIndex === -1 ? relativeModuleName : relativeModuleName.slice(0, separatorIndex); 531 | const leafRelativeModuleName = separatorIndex === -1 ? '' : relativeModuleName.slice(separatorIndex + 1); 532 | 533 | if (parentCondition === parentRelativeModuleName && leafRelativeModuleName) { 534 | return this.getExportsRelativeModulePaths(exportsNodeChild, join(ENTRY_POINT, leafRelativeModuleName)); 535 | } 536 | } 537 | 538 | return []; 539 | } 540 | 541 | private createEmptyModule(id: string): EmptyModule { 542 | const loadingModule = {}; 543 | const emptyModule = new Proxy({}, new CachedModuleProxyHandler(() => this.getCachedModule(id) ?? loadingModule)); 544 | return emptyModule as EmptyModule; 545 | } 546 | 547 | private deleteCacheEntry(id: string): void { 548 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 549 | delete this.modulesCache[id]; 550 | } 551 | 552 | private async findExistingFilePathAsync(path: string): Promise { 553 | for (const suffix of PATH_SUFFIXES) { 554 | const newPath = path + suffix; 555 | if (await this.existsFileAsync(newPath)) { 556 | return newPath; 557 | } 558 | } 559 | 560 | return null; 561 | } 562 | 563 | private async getDependenciesTimestampChangedAndReloadIfNeededAsync( 564 | path: string, 565 | cacheInvalidationMode: CacheInvalidationMode, 566 | moduleType?: ModuleType 567 | ): Promise { 568 | if (this.currentModulesTimestampChain.has(path)) { 569 | return this.moduleTimestamps.get(path) ?? 0; 570 | } 571 | 572 | this.currentModulesTimestampChain.add(path); 573 | 574 | const updateTimestamp = (newTimestamp: number): void => { 575 | timestamp = Math.max(timestamp, newTimestamp); 576 | this.moduleTimestamps.set(path, timestamp); 577 | }; 578 | 579 | const cachedTimestamp = this.moduleTimestamps.get(path) ?? 0; 580 | let timestamp = 0; 581 | updateTimestamp(await this.getTimestampAsync(path)); 582 | const dependencies = this.moduleDependencies.get(path) ?? []; 583 | for (const dependency of dependencies) { 584 | const { resolvedId, resolvedType } = this.resolve(dependency, path); 585 | switch (resolvedType) { 586 | case ResolvedType.Module: 587 | for (const rootFolder of await this.getRootFoldersAsync(path)) { 588 | const packageJsonPath = this.getPackageJsonPath(rootFolder); 589 | if (!await this.existsFileAsync(packageJsonPath)) { 590 | continue; 591 | } 592 | 593 | const dependencyTimestamp = await this.getDependenciesTimestampChangedAndReloadIfNeededAsync(packageJsonPath, cacheInvalidationMode); 594 | updateTimestamp(dependencyTimestamp); 595 | } 596 | break; 597 | case ResolvedType.Path: { 598 | const existingFilePath = await this.findExistingFilePathAsync(resolvedId); 599 | if (existingFilePath === null) { 600 | throw new Error(`File not found: ${resolvedId}`); 601 | } 602 | 603 | const dependencyTimestamp = await this.getDependenciesTimestampChangedAndReloadIfNeededAsync(existingFilePath, cacheInvalidationMode); 604 | updateTimestamp(dependencyTimestamp); 605 | break; 606 | } 607 | case ResolvedType.Url: { 608 | if (cacheInvalidationMode !== CacheInvalidationMode.Never) { 609 | updateTimestamp(Date.now()); 610 | } 611 | break; 612 | } 613 | default: 614 | throw new Error('Unknown resolvedType'); 615 | } 616 | } 617 | 618 | if (timestamp > cachedTimestamp || !this.getCachedModule(path)) { 619 | await this.initModuleAndAddToCacheAsync(path, () => this.requirePathImplAsync(path, moduleType)); 620 | } 621 | return timestamp; 622 | } 623 | 624 | private getExportsRelativeModulePaths(exportsNode: PackageJson.Exports | undefined, relativeModuleName: string): string[] { 625 | if (!exportsNode) { 626 | return []; 627 | } 628 | 629 | if (typeof exportsNode === 'string') { 630 | let path = exportsNode; 631 | 632 | if (!path.contains(MODULE_NAME_SEPARATOR)) { 633 | path = join(path, MODULE_NAME_SEPARATOR); 634 | } 635 | 636 | const resolvedPath = replaceAll(path, MODULE_NAME_SEPARATOR, relativeModuleName); 637 | return [resolvedPath]; 638 | } 639 | 640 | if (!Array.isArray(exportsNode)) { 641 | const conditions = exportsNode; 642 | return Object.entries(conditions) 643 | .flatMap(([condition, exportsNodeChild]) => this.applyCondition(condition, exportsNodeChild, relativeModuleName)); 644 | } 645 | 646 | const arr = exportsNode; 647 | return arr.flatMap((exportsNodeChild) => this.getExportsRelativeModulePaths(exportsNodeChild, relativeModuleName)); 648 | } 649 | 650 | private getParentPathFromCallStack(): null | string { 651 | /** 652 | * The caller line index is 4 because the call stack is as follows: 653 | * 654 | * 0: Error 655 | * 1: at CustomRequireImpl.getParentPathFromCallStack (plugin:fix-require-modules:?:?) 656 | * 2: at CustomRequireImpl.resolve (plugin:fix-require-modules:?:?) 657 | * 3: at CustomRequireImpl.require (plugin:fix-require-modules:?:?) 658 | * 4: at functionName (path/to/caller.js:?:?) 659 | */ 660 | const CALLER_LINE_INDEX = 4; 661 | const callStackLines = new Error().stack?.split('\n') ?? []; 662 | this.plugin.consoleDebug('callStackLines', { callStackLines }); 663 | const callStackMatch = callStackLines.at(CALLER_LINE_INDEX)?.match(/^ {4}at .+? \((?.+?):\d+:\d+\)$/); 664 | const parentPath = callStackMatch?.groups?.['ParentPath'] ?? null; 665 | 666 | if (parentPath?.includes('')) { 667 | return null; 668 | } 669 | 670 | return parentPath; 671 | } 672 | 673 | private async getRootFolderAsync(cwd: string): Promise { 674 | let currentFolder = toPosixPath(cwd); 675 | while (currentFolder !== '.' && currentFolder !== '/') { 676 | if (await this.existsFileAsync(this.getPackageJsonPath(currentFolder))) { 677 | return toPosixPath(currentFolder); 678 | } 679 | currentFolder = dirname(currentFolder); 680 | } 681 | return null; 682 | } 683 | 684 | private async getRootFoldersAsync(folder: string): Promise { 685 | const modulesRootFolder = this.plugin.settings.modulesRoot ? join(this.vaultAbsolutePath, this.plugin.settings.modulesRoot) : null; 686 | 687 | const ans: string[] = []; 688 | for (const possibleFolder of new Set([folder, modulesRootFolder])) { 689 | if (possibleFolder === null) { 690 | continue; 691 | } 692 | 693 | const rootFolder = await this.getRootFolderAsync(possibleFolder); 694 | if (rootFolder === null) { 695 | continue; 696 | } 697 | 698 | ans.push(rootFolder); 699 | } 700 | 701 | return ans; 702 | } 703 | 704 | private isEmptyModule(module: unknown): boolean { 705 | return (module as Partial | undefined)?.[EMPTY_MODULE_SYMBOL] === true; 706 | } 707 | 708 | private async readPackageJsonAsync(path: string): Promise { 709 | const content = await this.readFileAsync(path); 710 | return JSON.parse(content) as PackageJson; 711 | } 712 | 713 | private require(id: string, options?: Partial): unknown { 714 | const DEFAULT_OPTIONS: RequireOptions = { 715 | cacheInvalidationMode: CacheInvalidationMode.WhenPossible 716 | }; 717 | const fullOptions = { 718 | ...DEFAULT_OPTIONS, 719 | ...options 720 | }; 721 | const cleanId = splitQuery(id).cleanStr; 722 | const specialModule = this.requireSpecialModule(cleanId); 723 | if (specialModule) { 724 | if (specialModule === MODULE_TO_SKIP) { 725 | return null; 726 | } 727 | return specialModule; 728 | } 729 | 730 | const { resolvedId, resolvedType } = this.resolve(id, fullOptions.parentPath); 731 | 732 | let cleanResolvedId: string; 733 | let query: string; 734 | 735 | if (resolvedType === ResolvedType.Url) { 736 | cleanResolvedId = resolvedId; 737 | query = ''; 738 | } else { 739 | ({ cleanStr: cleanResolvedId, query } = splitQuery(resolvedId)); 740 | } 741 | 742 | const cachedModuleEntry = this.modulesCache[resolvedId]; 743 | 744 | if (cachedModuleEntry) { 745 | if (!cachedModuleEntry.loaded) { 746 | console.warn(`Circular dependency detected: ${resolvedId} -> ... -> ${fullOptions.parentPath ?? ''} -> ${resolvedId}`); 747 | return cachedModuleEntry.exports; 748 | } 749 | 750 | if (this.currentModulesTimestampChain.has(resolvedId)) { 751 | return cachedModuleEntry.exports; 752 | } 753 | 754 | switch (fullOptions.cacheInvalidationMode) { 755 | case CacheInvalidationMode.Never: 756 | return cachedModuleEntry.exports; 757 | case CacheInvalidationMode.WhenPossible: 758 | if (query) { 759 | return cachedModuleEntry.exports; 760 | } 761 | 762 | if (!this.canRequireNonCached(resolvedType)) { 763 | console.warn(`Cached module ${resolvedId} cannot be invalidated synchronously. The cached version will be used. `); 764 | return cachedModuleEntry.exports; 765 | } 766 | break; 767 | default: 768 | throw new Error('Unknown cacheInvalidationMode'); 769 | } 770 | } 771 | 772 | if (!this.canRequireNonCached(resolvedType)) { 773 | throw new Error(`Cannot require '${resolvedId}' synchronously. 774 | ${this.getRequireAsyncAdvice(true)}`); 775 | } 776 | 777 | const module = this.initModuleAndAddToCache( 778 | cleanResolvedId, 779 | () => this.requireNonCached(cleanResolvedId, resolvedType, fullOptions.cacheInvalidationMode, fullOptions.moduleType) 780 | ); 781 | if (resolvedId !== cleanResolvedId) { 782 | this.initModuleAndAddToCache(resolvedId, () => module); 783 | } 784 | return module; 785 | } 786 | 787 | private async requireJsonAsync(path: string, jsonStr?: string): Promise { 788 | jsonStr ??= await this.readFileAsync(splitQuery(path).cleanStr); 789 | return JSON.parse(jsonStr) as unknown; 790 | } 791 | 792 | private async requireJsTsAsync(path: string, code?: string): Promise { 793 | code ??= await this.readFileAsync(splitQuery(path).cleanStr); 794 | return this.requireStringAsync(code, path); 795 | } 796 | 797 | private async requireModuleAsync( 798 | moduleName: string, 799 | parentFolder: string, 800 | cacheInvalidationMode: CacheInvalidationMode, 801 | moduleType?: ModuleType 802 | ): Promise { 803 | let separatorIndex = moduleName.indexOf(RELATIVE_MODULE_PATH_SEPARATOR); 804 | 805 | if (moduleName.startsWith(SCOPED_MODULE_PREFIX)) { 806 | if (separatorIndex === -1) { 807 | throw new Error(`Invalid scoped module name: ${moduleName}`); 808 | } 809 | separatorIndex = moduleName.indexOf(RELATIVE_MODULE_PATH_SEPARATOR, separatorIndex + 1); 810 | } 811 | 812 | const baseModuleName = separatorIndex === -1 ? moduleName : moduleName.slice(0, separatorIndex); 813 | let relativeModuleName = ENTRY_POINT + (separatorIndex === -1 ? '' : moduleName.slice(separatorIndex)); 814 | 815 | for (const rootFolder of await this.getRootFoldersAsync(parentFolder)) { 816 | let packageFolder: string; 817 | if (moduleName.startsWith(PRIVATE_MODULE_PREFIX) || moduleName === ENTRY_POINT) { 818 | packageFolder = rootFolder; 819 | relativeModuleName = moduleName; 820 | } else { 821 | packageFolder = join(rootFolder, NODE_MODULES_FOLDER, baseModuleName); 822 | } 823 | 824 | if (!await this.existsFolderAsync(packageFolder)) { 825 | continue; 826 | } 827 | 828 | const packageJsonPath = this.getPackageJsonPath(packageFolder); 829 | if (!await this.existsFileAsync(packageJsonPath)) { 830 | continue; 831 | } 832 | 833 | const packageJson = await this.readPackageJsonAsync(packageJsonPath); 834 | const relativeModulePaths = this.getRelativeModulePaths(packageJson, relativeModuleName); 835 | 836 | for (const relativeModulePath of relativeModulePaths) { 837 | const fullModulePath = join(packageFolder, relativeModulePath); 838 | const existingPath = await this.findExistingFilePathAsync(fullModulePath); 839 | if (!existingPath) { 840 | continue; 841 | } 842 | 843 | return this.requirePathAsync(existingPath, cacheInvalidationMode, moduleType); 844 | } 845 | } 846 | 847 | throw new Error(`Could not resolve module: ${moduleName}`); 848 | } 849 | 850 | private async requireNonCachedAsync(id: string, type: ResolvedType, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): Promise { 851 | switch (type) { 852 | case ResolvedType.Module: { 853 | const [parentFolder = '', moduleName = ''] = id.split(MODULE_NAME_SEPARATOR); 854 | return await this.requireModuleAsync(moduleName, parentFolder, cacheInvalidationMode, moduleType); 855 | } 856 | case ResolvedType.Path: 857 | return await this.requirePathAsync(id, cacheInvalidationMode, moduleType); 858 | case ResolvedType.Url: 859 | return await this.requireUrlAsync(id, moduleType); 860 | default: 861 | throw new Error('Unknown resolvedType'); 862 | } 863 | } 864 | 865 | private async requirePathAsync(path: string, cacheInvalidationMode: CacheInvalidationMode, moduleType?: ModuleType): Promise { 866 | const existingFilePath = await this.findExistingFilePathAsync(path); 867 | if (existingFilePath === null) { 868 | throw new Error(`File not found: ${path}`); 869 | } 870 | 871 | const isRootRequire = this.currentModulesTimestampChain.size === 0; 872 | 873 | try { 874 | await this.getDependenciesTimestampChangedAndReloadIfNeededAsync(existingFilePath, cacheInvalidationMode, moduleType); 875 | } finally { 876 | if (isRootRequire) { 877 | this.currentModulesTimestampChain.clear(); 878 | } 879 | } 880 | 881 | return this.modulesCache[existingFilePath]?.exports; 882 | } 883 | 884 | private async requirePathImplAsync(path: string, moduleType?: ModuleType): Promise { 885 | moduleType ??= getModuleTypeFromPath(path); 886 | switch (moduleType) { 887 | case 'json': 888 | return this.requireJsonAsync(path); 889 | case 'jsTs': 890 | return this.requireJsTsAsync(path); 891 | case 'node': 892 | return this.requireNodeBinaryAsync(path); 893 | case 'wasm': 894 | return this.requireWasmAsync(path); 895 | default: 896 | throw new Error(`Unknown module type: ${moduleType as string}`); 897 | } 898 | } 899 | 900 | private async requireUrlAsync(url: string, moduleType?: ModuleType): Promise { 901 | const response = await requestUrl(url); 902 | moduleType ??= getModuleTypeFromContentType(response.headers['content-type'], url); 903 | 904 | switch (moduleType) { 905 | case 'json': 906 | return this.requireJsonAsync(url, response.text); 907 | case 'jsTs': 908 | return this.requireJsTsAsync(url, response.text); 909 | case 'node': 910 | return this.requireNodeBinaryAsync(url, response.arrayBuffer); 911 | case 'wasm': 912 | return this.requireWasmAsync(url, response.arrayBuffer); 913 | default: 914 | throw new Error(`Unknown module type: ${moduleType as string}`); 915 | } 916 | } 917 | 918 | private async requireWasmAsync(path: string, arrayBuffer?: ArrayBuffer): Promise { 919 | arrayBuffer ??= await this.readFileBinaryAsync(path); 920 | const wasm = await WebAssembly.instantiate(arrayBuffer); 921 | return wasm.instance.exports; 922 | } 923 | 924 | private wrapRequire(options: WrapRequireOptions): RequireExFn { 925 | function wrapped(id: string, requireOptions?: Partial): unknown { 926 | options.beforeRequire?.(id); 927 | const newOptions = { ...options.optionsToPrepend, ...requireOptions, ...options.optionsToAppend }; 928 | return options.require(id, newOptions); 929 | } 930 | 931 | return Object.assign( 932 | wrapped, 933 | options.require, 934 | normalizeOptionalProperties<{ parentPath?: string }>({ parentPath: options.optionsToPrepend?.parentPath }) 935 | ) as RequireExFn; 936 | } 937 | } 938 | 939 | export function getModuleTypeFromPath(path: string): ModuleType { 940 | const ext = extname(splitQuery(path).cleanStr); 941 | switch (ext) { 942 | case '.cjs': 943 | case '.cts': 944 | case '.js': 945 | case '.mjs': 946 | case '.mts': 947 | case '.ts': 948 | return 'jsTs'; 949 | case '.json': 950 | return 'json'; 951 | case '.node': 952 | return 'node'; 953 | case '.wasm': 954 | return 'wasm'; 955 | default: 956 | throw new Error(`Unsupported file extension: ${ext}`); 957 | } 958 | } 959 | 960 | export function splitQuery(str: string): SplitQueryResult { 961 | const queryIndex = str.indexOf('?'); 962 | return { 963 | cleanStr: queryIndex === -1 ? str : str.slice(0, queryIndex), 964 | query: queryIndex === -1 ? '' : str.slice(queryIndex) 965 | }; 966 | } 967 | 968 | export function trimNodePrefix(id: string): string { 969 | const NODE_BUILTIN_MODULE_PREFIX = 'node:'; 970 | return trimStart(id, NODE_BUILTIN_MODULE_PREFIX); 971 | } 972 | 973 | function convertPathToObsidianUrl(path: string): string { 974 | if (!isAbsolute(path)) { 975 | return path; 976 | } 977 | 978 | return Platform.resourcePathPrefix + path.replaceAll('\\', '/'); 979 | } 980 | 981 | function getModuleTypeFromContentType(contentType: string | undefined, url: string): ModuleType { 982 | contentType ??= ''; 983 | switch (contentType) { 984 | case 'application/javascript': 985 | case 'application/typescript': 986 | return 'jsTs'; 987 | case 'application/json': 988 | return 'json'; 989 | case 'application/octet-stream': 990 | return 'node'; 991 | case 'application/wasm': 992 | return 'wasm'; 993 | default: 994 | console.warn(`URL: ${url} returned unsupported content type: ${contentType}. 995 | Assuming it's a JavaScript/TypeScript file. 996 | Consider passing moduleType explicitly: 997 | 998 | const module = await requireAsync(url, { moduleType: 'jsTs' });`); 999 | return 'jsTs'; 1000 | } 1001 | } 1002 | -------------------------------------------------------------------------------- /src/RequireHandlerUtils.ts: -------------------------------------------------------------------------------- 1 | import { getPlatformDependencies } from './PlatformDependencies.ts'; 2 | import { VAULT_ROOT_PREFIX } from './RequireHandler.ts'; 3 | 4 | export async function requireStringAsync(source: string, path: string, urlSuffix?: string): Promise { 5 | const platformDependencies = await getPlatformDependencies(); 6 | return await platformDependencies.requireHandler.requireStringAsync(source, path, urlSuffix); 7 | } 8 | 9 | export async function requireVaultScriptAsync(id: string): Promise { 10 | const platformDependencies = await getPlatformDependencies(); 11 | return await platformDependencies.requireHandler.requireAsync(VAULT_ROOT_PREFIX + id); 12 | } 13 | -------------------------------------------------------------------------------- /src/Script.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | App, 3 | DataAdapter 4 | } from 'obsidian'; 5 | import type { Promisable } from 'type-fest'; 6 | 7 | import { Notice } from 'obsidian'; 8 | import { printError } from 'obsidian-dev-utils/Error'; 9 | import { selectItem } from 'obsidian-dev-utils/obsidian/Modals/SelectItem'; 10 | import { basename } from 'obsidian-dev-utils/Path'; 11 | 12 | import type { Plugin } from './Plugin.ts'; 13 | 14 | import { requireVaultScriptAsync } from './RequireHandlerUtils.ts'; 15 | 16 | interface CleanupScript extends Script { 17 | cleanup(app: App): Promisable; 18 | } 19 | 20 | interface Script { 21 | invoke(app: App): Promisable; 22 | } 23 | 24 | const extensions = ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']; 25 | 26 | export async function cleanupStartupScript(plugin: Plugin): Promise { 27 | const startupScriptPath = await validateStartupScript(plugin); 28 | if (!startupScriptPath) { 29 | return; 30 | } 31 | 32 | const script = await requireVaultScriptAsync(startupScriptPath) as Partial; 33 | await script.cleanup?.(plugin.app); 34 | } 35 | 36 | export async function invokeStartupScript(plugin: Plugin): Promise { 37 | const startupScriptPath = await validateStartupScript(plugin); 38 | if (!startupScriptPath) { 39 | return; 40 | } 41 | 42 | await invoke(plugin, startupScriptPath, true); 43 | } 44 | 45 | export async function registerInvocableScripts(plugin: Plugin): Promise { 46 | const COMMAND_NAME_PREFIX = 'invokeScriptFile-'; 47 | const commands = plugin.app.commands.listCommands().filter((c) => c.id.startsWith(`${plugin.manifest.id}:${COMMAND_NAME_PREFIX}`)); 48 | for (const command of commands) { 49 | plugin.app.commands.removeCommand(command.id); 50 | } 51 | 52 | const invocableScriptsFolder = plugin.settings.getInvocableScriptsFolder(); 53 | 54 | if (!invocableScriptsFolder) { 55 | return; 56 | } 57 | 58 | if (!await plugin.app.vault.adapter.exists(invocableScriptsFolder)) { 59 | const message = `Invocable scripts folder not found: ${invocableScriptsFolder}`; 60 | new Notice(message); 61 | console.error(message); 62 | return; 63 | } 64 | 65 | const scriptFiles = await getAllScriptFiles(plugin.app.vault.adapter, plugin.settings.getInvocableScriptsFolder(), ''); 66 | 67 | for (const scriptFile of scriptFiles) { 68 | plugin.addCommand({ 69 | callback: async () => { 70 | await invoke(plugin, `${invocableScriptsFolder}/${scriptFile}`); 71 | }, 72 | id: `${COMMAND_NAME_PREFIX}${scriptFile}`, 73 | name: `Invoke Script: ${scriptFile}` 74 | }); 75 | } 76 | } 77 | 78 | export async function reloadStartupScript(plugin: Plugin): Promise { 79 | const startupScriptPath = await validateStartupScript(plugin, true); 80 | if (!startupScriptPath) { 81 | return; 82 | } 83 | 84 | await cleanupStartupScript(plugin); 85 | await invokeStartupScript(plugin); 86 | } 87 | 88 | export async function selectAndInvokeScript(plugin: Plugin): Promise { 89 | const app = plugin.app; 90 | const invocableScriptsFolder = plugin.settings.getInvocableScriptsFolder(); 91 | let scriptFiles: string[]; 92 | 93 | if (!invocableScriptsFolder) { 94 | scriptFiles = ['Error: No Invocable scripts folder specified in the settings']; 95 | } else if (await app.vault.adapter.exists(invocableScriptsFolder)) { 96 | scriptFiles = await getAllScriptFiles(app.vault.adapter, invocableScriptsFolder, ''); 97 | } else { 98 | scriptFiles = [`Error: Invocable scripts folder not found: ${invocableScriptsFolder}`]; 99 | } 100 | 101 | const scriptFile = await selectItem({ 102 | app, 103 | items: scriptFiles, 104 | itemTextFunc: (script) => script, 105 | placeholder: 'Choose a script to invoke' 106 | }); 107 | 108 | if (scriptFile === null) { 109 | plugin.consoleDebug('No script selected'); 110 | return; 111 | } 112 | 113 | if (!scriptFile.startsWith('Error:')) { 114 | await invoke(plugin, `${invocableScriptsFolder}/${scriptFile}`); 115 | } 116 | } 117 | 118 | async function getAllScriptFiles(adapter: DataAdapter, scriptsFolder: string, folder: string): Promise { 119 | const files: string[] = []; 120 | const listedFiles = await adapter.list(`${scriptsFolder}/${folder}`); 121 | for (const fileName of getSortedBaseNames(listedFiles.files)) { 122 | const lowerCasedFileName = fileName.toLowerCase(); 123 | if (extensions.some((ext) => lowerCasedFileName.endsWith(ext))) { 124 | files.push(folder ? `${folder}/${fileName}` : fileName); 125 | } 126 | } 127 | for (const folderName of getSortedBaseNames(listedFiles.folders)) { 128 | const subFiles = await getAllScriptFiles(adapter, scriptsFolder, folder ? `${folder}/${folderName}` : folderName); 129 | files.push(...subFiles); 130 | } 131 | 132 | return files; 133 | } 134 | 135 | function getSortedBaseNames(fullNames: string[]): string[] { 136 | return fullNames.map((file) => basename(file)).sort((a, b) => a.localeCompare(b)); 137 | } 138 | 139 | async function invoke(plugin: Plugin, scriptPath: string, isStartup?: boolean): Promise { 140 | const app = plugin.app; 141 | const scriptString = isStartup ? 'startup script' : 'script'; 142 | plugin.consoleDebug(`Invoking ${scriptString}: ${scriptPath}`); 143 | try { 144 | if (!await app.vault.adapter.exists(scriptPath)) { 145 | throw new Error(`Script not found: ${scriptPath}`); 146 | } 147 | const script = await requireVaultScriptAsync(scriptPath) as Partial