├── .editorconfig ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ └── releases.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .prettierrc ├── README.md ├── jest.config.js ├── manifest.json ├── package.json ├── src ├── cmExtension │ ├── suggestionsExtension.css │ └── suggestionsExtension.ts ├── components │ ├── suggestionsPopup.css │ └── suggestionsPopup.ts ├── index.ts ├── indexing │ └── indexer.ts ├── plugin-helper.ts ├── search │ ├── index.ts │ ├── mapStemToOriginalText.ts │ ├── redactText.spec.ts │ ├── redactText.ts │ └── search.spec.ts ├── stemmers │ └── index.ts ├── tokenizers │ ├── index.ts │ └── tokenizer.spec.ts └── utils │ ├── getAliases.spec.ts │ └── getAliases.ts ├── tsconfig.json ├── versions.json └── webpack.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.md] 10 | insert_final_newline = false 11 | trim_trailing_whitespace = false 12 | 13 | [*.{js,jsx,json,ts,tsx,yml,svelte,svg}] 14 | indent_size = 2 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 6 | rules: { 7 | '@typescript-eslint/no-unused-vars': [2, { args: 'all', argsIgnorePattern: '^_' }], 8 | }, 9 | env: { 10 | node: true, 11 | }, 12 | overrides: [ 13 | { 14 | files: ['*.js'], 15 | rules: { 16 | '@typescript-eslint/no-var-requires': 'off', 17 | }, 18 | }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [hadynz] 2 | custom: ["https://buymeacoffee.com/hadynz"] 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | lint-and-test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: '15.x' 16 | 17 | - name: Install modules 18 | run: npm install 19 | 20 | - name: Lint 21 | run: npm run lint 22 | 23 | - name: Run tests 24 | run: npm run test 25 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | PLUGIN_NAME: obsidian-kindle-plugin 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: '15.x' 19 | 20 | - name: Build 21 | id: build 22 | env: 23 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 24 | run: | 25 | npm install 26 | npm run lint 27 | npm run test 28 | npm run build 29 | zip -r -j ${{ env.PLUGIN_NAME }}.zip dist -x "*.map" 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Upload main.js 34 | id: upload-main 35 | uses: actions/upload-release-asset@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | upload_url: ${{ github.event.release.upload_url }} 40 | asset_path: ./dist/main.js 41 | asset_name: main.js 42 | asset_content_type: text/javascript 43 | 44 | - name: Upload manifest.json 45 | id: upload-manifest 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ github.event.release.upload_url }} 51 | asset_path: ./dist/manifest.json 52 | asset_name: manifest.json 53 | asset_content_type: application/json 54 | 55 | - name: Upload plugin zip package 56 | id: upload-zip-package 57 | uses: actions/upload-release-asset@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | upload_url: ${{ github.event.release.upload_url }} 62 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 63 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 64 | asset_content_type: application/zip 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | 8 | # nunjucks file 9 | main.js.LICENSE.txt 10 | 11 | # eslint 12 | .eslintcache 13 | 14 | # obsidian plugin files 15 | main.js 16 | main.js.map 17 | data.json 18 | 19 | coverage 20 | dist 21 | tsconfig.tsbuildinfo 22 | .env 23 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 95, 3 | "svelteSortOrder": "scripts-markup-styles", 4 | "svelteBracketNewLine": true, 5 | "svelteAllowShorthand": true, 6 | "svelteIndentScriptAndStyle": true, 7 | "singleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sidekick 2 | 3 | ![CI/CD status](https://github.com/hadynz/obsidian-sidekick/actions/workflows/main.yml/badge.svg) 4 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/hadynz/obsidian-sidekick) 5 | 6 | An [Obsidian][3] plugin that auto-suggests new connections to make by underlining text that matches 7 | existing tags or links in your vault. 8 | 9 | Support development: @hadynz on [Twitter][2] or [Buy me a coffee][1] 10 | 11 | 12 | 13 | ## About Sidekick 14 | 15 | ### Goals 16 | 17 | * Increase the number of connections between existing ideas 18 | * Unobtrusive and near real-time automatic suggestion of connections during editing 19 | 20 | ### Preview 21 | 22 | ![Kapture 2022-02-11 at 00 34 23](https://user-images.githubusercontent.com/315585/153401639-11a295c6-ab3e-4afd-945e-9fc3043d74a2.gif) 23 | 24 | ## My other plugins 25 | 26 | * [Kindle Highlights][4] - sync your Kindle notes and highlights directly into your Obsidian vault 27 | 28 | ## License 29 | 30 | [MIT](LICENSE) 31 | 32 | [1]: https://www.buymeacoffee.com/hadynz 33 | [2]: https://twitter.com/hadynz 34 | [3]: https://obsidian.md 35 | [4]: https://github.com/hadynz/obsidian-kindle-plugin -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | moduleNameMapper: { 6 | '^~/(.*)': '/src/$1', 7 | }, 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 9 | testRegex: '(/(tests|src)/.*.(test|spec))\\.(ts|js)x?$', 10 | coverageDirectory: 'coverage', 11 | collectCoverageFrom: ['src/**/*.test.{ts,tsx,js,jsx}', '!src/**/*.d.ts'], 12 | }; 13 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-sidekick", 3 | "name": "Sidekick", 4 | "description": "A companion to identify hidden connections that match your tags and pages", 5 | "version": "1.5.1", 6 | "minAppVersion": "0.13.8", 7 | "author": "Hady Osman", 8 | "authorUrl": "https://hady.geek.nz", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sidekick", 3 | "version": "1.5.1", 4 | "description": "A companion to identify hidden connections that match your tags and pages", 5 | "main": "src/index.ts", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/hadynz/obsidian-sidekick.git" 9 | }, 10 | "keywords": [ 11 | "obsidian", 12 | "autocomplete", 13 | "autosuggestion", 14 | "connections" 15 | ], 16 | "author": { 17 | "name": "Hady Osman", 18 | "email": "hadyos@gmail.com", 19 | "url": "https://hady.geek.nz" 20 | }, 21 | "license": "MIT", 22 | "scripts": { 23 | "prettier": "prettier --write '**/*.{ts,js,css,html}'", 24 | "lint": "tsc --noemit && eslint . --ext .ts", 25 | "clean": "rimraf dist main.js*", 26 | "test": "jest --passWithNoTests --verbose", 27 | "test-watch": "jest --watch", 28 | "dev": "NODE_ENV=development webpack && cp ./dist/main.js* .", 29 | "build": "NODE_ENV=production webpack", 30 | "prepare": "husky install" 31 | }, 32 | "devDependencies": { 33 | "@codemirror/rangeset": "^0.19.7", 34 | "@codemirror/state": "^0.19.9", 35 | "@codemirror/view": "^0.19.42", 36 | "@types/faker": "^5.5.8", 37 | "@types/jest": "^26.0.22", 38 | "@types/lodash": "^4.14.178", 39 | "@types/lokijs": "^1.5.7", 40 | "@types/webpack": "^5.28.0", 41 | "@typescript-eslint/eslint-plugin": "^4.22.0", 42 | "@typescript-eslint/parser": "^4.22.0", 43 | "babel-loader": "^8.2.2", 44 | "copy-webpack-plugin": "^10.0.0", 45 | "css-loader": "^6.6.0", 46 | "eslint": "^7.24.0", 47 | "faker": "^5.5.3", 48 | "file-loader": "^6.2.0", 49 | "husky": "^7.0.4", 50 | "jest": "^26.6.3", 51 | "lint-staged": "^11.2.4", 52 | "obsidian": "^0.13.11", 53 | "prettier": "^2.2.1", 54 | "rimraf": "^3.0.2", 55 | "style-loader": "^3.3.1", 56 | "terser-webpack-plugin": "^5.2.5", 57 | "ts-jest": "^26.5.4", 58 | "ts-loader": "^8.1.0", 59 | "ts-node": "^10.5.0", 60 | "typescript": "^4.2.3", 61 | "uglify-js": "^3.15.1", 62 | "url-loader": "^4.1.1", 63 | "webpack": "^5.30.0", 64 | "webpack-cli": "^4.6.0", 65 | "webpack-node-externals": "^2.5.2" 66 | }, 67 | "lint-staged": { 68 | "*.ts": "eslint --cache --fix", 69 | "*.{js,css,md}": "prettier --write" 70 | }, 71 | "dependencies": { 72 | "@tanishiking/aho-corasick": "^0.0.1", 73 | "@types/natural": "^5.1.0", 74 | "lodash": "^4.17.21", 75 | "lokijs": "^1.5.12", 76 | "natural": "^5.1.13", 77 | "tiny-typed-emitter": "^2.1.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/cmExtension/suggestionsExtension.css: -------------------------------------------------------------------------------- 1 | .cm-suggestion-candidate { 2 | position: relative; 3 | border-bottom: 2px solid var(--interactive-accent); 4 | display: inline-block; 5 | } 6 | 7 | .cm-suggestion-candidate::before { 8 | content: ''; 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | bottom: 0; 13 | right: 0; 14 | background-color: var(--interactive-accent); 15 | opacity: 0.25; 16 | z-index: -1; 17 | } 18 | -------------------------------------------------------------------------------- /src/cmExtension/suggestionsExtension.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Decoration, 3 | EditorView, 4 | ViewPlugin, 5 | ViewUpdate, 6 | type DecorationSet, 7 | type PluginValue, 8 | } from '@codemirror/view'; 9 | import { RangeSetBuilder } from '@codemirror/rangeset'; 10 | import { App, debounce, type Debouncer } from 'obsidian'; 11 | 12 | import { showSuggestionsModal } from '../components/suggestionsPopup'; 13 | import type Search from '../search'; 14 | 15 | import './suggestionsExtension.css'; 16 | 17 | const SuggestionCandidateClass = 'cm-suggestion-candidate'; 18 | 19 | const underlineDecoration = (start: number, end: number, indexKeyword: string) => 20 | Decoration.mark({ 21 | class: SuggestionCandidateClass, 22 | attributes: { 23 | 'data-index-keyword': indexKeyword, 24 | 'data-position-start': `${start}`, 25 | 'data-position-end': `${end}`, 26 | }, 27 | }); 28 | 29 | export const suggestionsExtension = (search: Search, app: App): ViewPlugin => { 30 | return ViewPlugin.fromClass( 31 | class { 32 | decorations: DecorationSet; 33 | delayedDecorateView: Debouncer<[view: EditorView]>; 34 | 35 | constructor(view: EditorView) { 36 | this.updateDebouncer(view); 37 | this.decorations = this.decorateView(view); 38 | } 39 | 40 | public update(update: ViewUpdate): void { 41 | if (update.docChanged || update.viewportChanged) { 42 | this.delayedDecorateView(update.view); 43 | } 44 | } 45 | 46 | private updateDebouncer(_view: EditorView) { 47 | this.delayedDecorateView = debounce( 48 | (view: EditorView) => { 49 | this.decorations = this.decorateView(view); 50 | view.update([]); // force a view update so that the decorations we just set get applied 51 | }, 52 | 1000, 53 | true 54 | ); 55 | } 56 | 57 | private decorateView(view: EditorView): DecorationSet { 58 | const builder = new RangeSetBuilder(); 59 | 60 | // Decorate visible ranges only for performance reasons 61 | for (const { from, to } of view.visibleRanges) { 62 | const textToHighlight = view.state.sliceDoc(from, to); 63 | const results = textToHighlight ? search.find(textToHighlight) : []; 64 | 65 | for (const result of results) { 66 | // Offset result by the start of the visible range 67 | const start = from + result.start; 68 | const end = from + result.end; 69 | 70 | // Add the decoration 71 | builder.add(start, end, underlineDecoration(start, end, result.indexKeyword)); 72 | } 73 | } 74 | 75 | return builder.finish(); 76 | } 77 | }, 78 | { 79 | decorations: (view) => view.decorations, 80 | 81 | eventHandlers: { 82 | mousedown: (e: MouseEvent, view: EditorView) => { 83 | const target = e.target as HTMLElement; 84 | const isCandidate = target.classList.contains(SuggestionCandidateClass); 85 | 86 | // Do nothing if user right-clicked or unrelated DOM element was clicked 87 | if (!isCandidate || e.button !== 0) { 88 | return; 89 | } 90 | 91 | // Extract position and replacement text from target element data attributes state 92 | const { positionStart, positionEnd, indexKeyword } = target.dataset; 93 | 94 | // Show suggestions modal 95 | showSuggestionsModal({ 96 | app, 97 | mouseEvent: e, 98 | suggestions: search.getReplacementSuggestions(indexKeyword), 99 | onClick: (replaceText) => { 100 | view.dispatch({ 101 | changes: { 102 | from: +positionStart, 103 | to: +positionEnd, 104 | insert: replaceText, 105 | }, 106 | }); 107 | }, 108 | }); 109 | }, 110 | }, 111 | } 112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /src/components/suggestionsPopup.css: -------------------------------------------------------------------------------- 1 | .tippy-box[data-theme~='obsidian'] { 2 | background-color: var(--background-secondary-alt); 3 | color: var(--text-normal); 4 | } 5 | 6 | .tippy-box[data-theme~='obsidian'] .tippy-content { 7 | padding: 5px 7px 7px; 8 | } 9 | 10 | .tippy-box[data-theme~='obsidian'] .tippy-arrow { 11 | color: var(--background-secondary-alt); 12 | } 13 | 14 | .tippy-box[data-theme~='obsidian'] button { 15 | padding: 4px 14px; 16 | margin: 0; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/suggestionsPopup.ts: -------------------------------------------------------------------------------- 1 | import { App, Menu, MenuItem } from 'obsidian'; 2 | 3 | type SuggestionsModalProps = { 4 | app: App; 5 | mouseEvent: MouseEvent; 6 | suggestions: string[]; 7 | onClick: (replaceText: string) => void; 8 | }; 9 | 10 | const item = (icon, title, click) => { 11 | return (item: MenuItem) => item.setIcon(icon).setTitle(title).onClick(click); 12 | }; 13 | 14 | export const showSuggestionsModal = (props: SuggestionsModalProps): void => { 15 | const { app, mouseEvent, suggestions, onClick } = props; 16 | 17 | setTimeout(() => { 18 | const menu = new Menu(app); 19 | 20 | suggestions.forEach((replaceText) => { 21 | menu.addItem( 22 | item('pencil', `Replace with ${replaceText}`, () => { 23 | onClick(replaceText); 24 | }) 25 | ); 26 | }); 27 | 28 | menu.addSeparator(); 29 | menu.showAtMouseEvent(mouseEvent); 30 | }, 100); 31 | }; 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | import type { Extension } from '@codemirror/state'; 3 | 4 | import Search from './search'; 5 | import { PluginHelper } from './plugin-helper'; 6 | import { Indexer } from './indexing/indexer'; 7 | import { suggestionsExtension } from './cmExtension/suggestionsExtension'; 8 | 9 | export default class TagsAutosuggestPlugin extends Plugin { 10 | private editorExtension: Extension[] = []; 11 | 12 | public async onload(): Promise { 13 | console.log('Autosuggest plugin: loading plugin', new Date().toLocaleString()); 14 | 15 | const pluginHelper = new PluginHelper(this); 16 | const indexer = new Indexer(pluginHelper); 17 | 18 | this.registerEditorExtension(this.editorExtension); 19 | 20 | // Update index for any file that was modified in the vault 21 | pluginHelper.onFileRename((file) => indexer.replaceFileIndices(file)); 22 | pluginHelper.onFileMetadataChanged((file) => indexer.replaceFileIndices(file)); 23 | 24 | // Re/load highlighting extension after any changes to index 25 | indexer.on('indexRebuilt', () => { 26 | const search = new Search(indexer); 27 | this.updateEditorExtension(suggestionsExtension(search, this.app)); 28 | }); 29 | 30 | indexer.on('indexUpdated', () => { 31 | const search = new Search(indexer); 32 | this.updateEditorExtension(suggestionsExtension(search, this.app)); 33 | }); 34 | 35 | // Build search index on startup (very expensive process) 36 | pluginHelper.onLayoutReady(() => indexer.buildIndex()); 37 | } 38 | 39 | /** 40 | * Ref: https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md#how-to-changereconfigure-your-cm6-extensions 41 | */ 42 | private updateEditorExtension(extension: Extension) { 43 | this.editorExtension.length = 0; 44 | this.editorExtension.push(extension); 45 | this.app.workspace.updateOptions(); 46 | } 47 | 48 | public async onunload(): Promise { 49 | console.log('Autosuggest plugin: unloading plugin', new Date().toLocaleString()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/indexing/indexer.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import lokijs from 'lokijs'; 3 | import { TypedEmitter } from 'tiny-typed-emitter'; 4 | import type { TFile } from 'obsidian'; 5 | 6 | import { stemPhrase } from '../stemmers'; 7 | import { WordPermutationsTokenizer } from '../tokenizers'; 8 | import type { PluginHelper } from '../plugin-helper'; 9 | 10 | type Document = { 11 | fileCreationTime: number; 12 | type: 'tag' | 'alias' | 'page' | 'page-token'; 13 | keyword: string; 14 | originalText: string; 15 | replaceText: string; 16 | }; 17 | 18 | interface IndexerEvents { 19 | indexRebuilt: () => void; 20 | indexUpdated: () => void; 21 | } 22 | 23 | export class Indexer extends TypedEmitter { 24 | private documents: Collection; 25 | private permutationTokenizer: WordPermutationsTokenizer; 26 | 27 | constructor(private pluginHelper: PluginHelper) { 28 | super(); 29 | 30 | const db = new lokijs('sidekick'); 31 | 32 | this.documents = db.addCollection('documents', { 33 | indices: ['fileCreationTime', 'keyword'], 34 | }); 35 | 36 | this.permutationTokenizer = new WordPermutationsTokenizer(); 37 | } 38 | 39 | public getKeywords(): string[] { 40 | const keywords = this.documents 41 | .find({ 42 | fileCreationTime: { $ne: this.pluginHelper.activeFile.stat.ctime }, // Always exclude indices related to active file 43 | }) 44 | .map((doc) => doc.keyword); 45 | 46 | return _.uniq(keywords); 47 | } 48 | 49 | public getDocumentsByKeyword(keyword: string): Document[] { 50 | return this.documents.find({ 51 | keyword, 52 | fileCreationTime: { $ne: this.pluginHelper.activeFile.stat.ctime }, // Always exclude indices related to active file 53 | }); 54 | } 55 | 56 | public buildIndex(): void { 57 | this.pluginHelper.getAllFiles().forEach((file) => this.indexFile(file)); 58 | this.emit('indexRebuilt'); 59 | } 60 | 61 | public replaceFileIndices(file: TFile): void { 62 | // Remove all indices related to modified file 63 | this.documents.findAndRemove({ fileCreationTime: file.stat.ctime }); 64 | 65 | // Re-index modified file 66 | this.indexFile(file); 67 | 68 | this.emit('indexUpdated'); 69 | } 70 | 71 | private indexFile(file: TFile): void { 72 | this.documents.insert({ 73 | fileCreationTime: file.stat.ctime, 74 | type: 'page', 75 | keyword: stemPhrase(file.basename), 76 | originalText: file.basename, 77 | replaceText: `[[${file.basename}]]`, 78 | }); 79 | 80 | this.permutationTokenizer.tokenize(file.basename).forEach((token) => { 81 | this.documents.insert({ 82 | fileCreationTime: file.stat.ctime, 83 | type: 'page-token', 84 | keyword: token, 85 | originalText: file.basename, 86 | replaceText: `[[${file.basename}]]`, 87 | }); 88 | }); 89 | 90 | this.pluginHelper.getAliases(file).forEach((alias) => { 91 | this.documents.insert({ 92 | fileCreationTime: file.stat.ctime, 93 | type: 'alias', 94 | keyword: alias.toLowerCase(), 95 | originalText: file.basename, 96 | replaceText: `[[${file.basename}|${alias}]]`, 97 | }); 98 | }); 99 | 100 | this.pluginHelper.getTags(file).forEach((tag) => { 101 | this.documents.insert({ 102 | fileCreationTime: file.stat.ctime, 103 | type: 'tag', 104 | keyword: tag.replace(/#/, '').toLowerCase(), 105 | originalText: tag, 106 | replaceText: tag, 107 | }); 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/plugin-helper.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { parseFrontMatterTags, TFile, Plugin } from 'obsidian'; 3 | 4 | import { getAliases } from './utils/getAliases'; 5 | 6 | export class PluginHelper { 7 | constructor(private plugin: Plugin) {} 8 | 9 | public get activeFile(): TFile | undefined { 10 | return this.plugin.app.workspace.getActiveFile(); 11 | } 12 | 13 | public getAliases(file: TFile): string[] { 14 | return getAliases(this.plugin.app.metadataCache.getFileCache(file)); 15 | } 16 | 17 | public getTags(file: TFile): string[] { 18 | const tags = this.getFrontMatterTags(file).concat( 19 | this.plugin.app.metadataCache.getFileCache(file)?.tags?.map((x) => x.tag) ?? [] 20 | ); 21 | return _.uniq(tags); 22 | } 23 | 24 | public getAllFiles(): TFile[] { 25 | return this.plugin.app.vault.getMarkdownFiles(); 26 | } 27 | 28 | public onLayoutReady(callback: () => void): void { 29 | this.plugin.app.workspace.onLayoutReady(() => callback()); 30 | } 31 | 32 | public onFileRename(callback: (file: TFile) => void): void { 33 | this.plugin.app.workspace.onLayoutReady(() => { 34 | this.plugin.registerEvent( 35 | this.plugin.app.vault.on('rename', (fileOrFolder) => { 36 | if (fileOrFolder instanceof TFile) { 37 | callback(fileOrFolder); 38 | } 39 | }) 40 | ); 41 | }); 42 | } 43 | 44 | public onFileMetadataChanged(callback: (file: TFile) => void): void { 45 | this.plugin.app.workspace.onLayoutReady(() => { 46 | this.plugin.registerEvent(this.plugin.app.metadataCache.on('changed', callback)); 47 | }); 48 | } 49 | 50 | private getFrontMatterTags(file: TFile): string[] { 51 | return ( 52 | parseFrontMatterTags(this.plugin.app.metadataCache.getFileCache(file)?.frontmatter) ?? [] 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/search/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { Trie } from '@tanishiking/aho-corasick'; 3 | 4 | import type { Indexer } from '../indexing/indexer'; 5 | import { redactText } from './redactText'; 6 | import { mapStemToOriginalText } from './mapStemToOriginalText'; 7 | import { WordPunctStemTokenizer } from '../tokenizers'; 8 | 9 | const tokenizer = new WordPunctStemTokenizer(); 10 | 11 | export type SearchResult = { 12 | start: number; 13 | end: number; 14 | indexKeyword: string; 15 | originalKeyword: string; 16 | }; 17 | 18 | const isEqual = (a: SearchResult, b: SearchResult) => { 19 | return a.start === b.start && a.indexKeyword === b.indexKeyword; 20 | }; 21 | 22 | export default class Search { 23 | private trie: Trie; 24 | 25 | constructor(private indexer: Indexer) { 26 | const keywords = this.indexer.getKeywords(); 27 | 28 | // Generating the Trie is expensive, so we only do it once 29 | this.trie = new Trie(keywords, { 30 | allowOverlaps: false, 31 | onlyWholeWords: true, 32 | caseInsensitive: true, 33 | }); 34 | } 35 | 36 | public getReplacementSuggestions(keyword: string): string[] { 37 | const keywords = this.indexer.getDocumentsByKeyword(keyword).map((doc) => doc.replaceText); 38 | return _.uniq(keywords); 39 | } 40 | 41 | public find(text: string): SearchResult[] { 42 | const redactedText = redactText(text); // Redact text that we don't want to be searched 43 | 44 | // Stem the text 45 | const tokens = tokenizer.tokenize(redactedText); 46 | const stemmedText = tokens.map((t) => t.stem).join(''); 47 | 48 | // Search stemmed text 49 | const emits = this.trie.parseText(stemmedText); 50 | 51 | // Map stemmed results to original text 52 | return _.chain(emits) 53 | .map((emit) => mapStemToOriginalText(emit, tokens)) 54 | .uniqWith(isEqual) 55 | .filter((result) => this.keywordExistsInIndex(result.indexKeyword)) 56 | .sort((a, b) => a.start - b.start) // Must sort by start position to prepare for highlighting 57 | .value(); 58 | } 59 | 60 | private keywordExistsInIndex(index: string): boolean { 61 | const exists = this.indexer.getDocumentsByKeyword(index).length > 0; 62 | 63 | if (!exists) { 64 | console.warn( 65 | `Search hit "${index}" was not found in Obsidian index. This could be a bug. Report on https://github.com/hadynz/obsidian-sidekick/issues`, 66 | this.indexer 67 | ); 68 | } 69 | 70 | return exists; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/search/mapStemToOriginalText.ts: -------------------------------------------------------------------------------- 1 | import { Emit } from '@tanishiking/aho-corasick'; 2 | 3 | import { SearchResult } from '../search/index'; 4 | import { Token } from '../tokenizers'; 5 | 6 | /** 7 | * Takes a given search result (which has the start/end position and a "stemmed" keyword) 8 | * that was matched, and maps them to a new start/end position for the original keyword 9 | * which was stem was created from 10 | * @param searchResult 11 | * @param tokens 12 | * @returns 13 | */ 14 | export const mapStemToOriginalText = (searchResult: Emit, tokens: Token[]): SearchResult => { 15 | const matchingTokens = tokens.filter( 16 | (token) => token.stemStart >= searchResult.start && token.stemEnd <= searchResult.end + 1 17 | ); 18 | 19 | return { 20 | start: matchingTokens[0].originalStart, 21 | end: matchingTokens[matchingTokens.length - 1].originalEnd, 22 | indexKeyword: matchingTokens 23 | .map((token) => token.stem) 24 | .join('') 25 | .toLowerCase(), 26 | originalKeyword: matchingTokens.map((token) => token.originalText).join(''), 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/search/redactText.spec.ts: -------------------------------------------------------------------------------- 1 | import { redactText } from './redactText'; 2 | 3 | describe('redactText', () => { 4 | it('Hashtags are redacted', () => { 5 | const sentence = 'I love playing #football'; 6 | const expected = 'I love playing '; 7 | 8 | const actual = redactText(sentence); 9 | 10 | expect(actual).toEqual(expected); 11 | }); 12 | 13 | it('Hierarchial hashtags are redacted', () => { 14 | const sentence = 'I love playing #sport/football'; 15 | const expected = 'I love playing '; 16 | 17 | const actual = redactText(sentence); 18 | 19 | expect(actual).toEqual(expected); 20 | }); 21 | 22 | it('Links are redacted', () => { 23 | const sentence = 'I love [[sleeping]] and [[https://aoe.com|gaming]]'; 24 | const expected = 'I love and '; 25 | 26 | const actual = redactText(sentence); 27 | 28 | expect(actual).toEqual(expected); 29 | }); 30 | 31 | it('Code blocks are redacted', () => { 32 | const sentence = '```cs\ 33 | code block\ 34 | ```'; 35 | const expected = ' \ 36 | \ 37 | '; 38 | 39 | const actual = redactText(sentence); 40 | 41 | expect(actual).toEqual(expected); 42 | }); 43 | 44 | it('Frontmatter is redacted', () => { 45 | const sentence = '---\ 46 | tags: [aoe, aoe2]\ 47 | ---\ 48 | # Heading 1\ 49 | ```'; 50 | const expected = ' \ 51 | \ 52 | \ 53 | # Heading 1\ 54 | ```'; 55 | 56 | const actual = redactText(sentence); 57 | 58 | expect(actual).toEqual(expected); 59 | }); 60 | 61 | it('Frontmatter with preceding empty lines is redacted', () => { 62 | const sentence = '\ 63 | \ 64 | ---\ 65 | tags: [aoe, aoe2]\ 66 | ---\ 67 | # Heading 1\ 68 | ```'; 69 | const expected = '\ 70 | \ 71 | \ 72 | \ 73 | \ 74 | # Heading 1\ 75 | ```'; 76 | 77 | const actual = redactText(sentence); 78 | 79 | expect(actual).toEqual(expected); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/search/redactText.ts: -------------------------------------------------------------------------------- 1 | export const redactText = (text: string): string => { 2 | return text 3 | .replace(/```[\s\S]+?```/g, (m) => ' '.repeat(m.length)) // remove code blocks 4 | .replace(/^\n*?---[\s\S]+?---/g, (m) => ' '.repeat(m.length)) // remove yaml front matter 5 | .replace(/#+([a-zA-Z0-9_/]+)/g, (m) => ' '.repeat(m.length)) // remove hashtags 6 | .replace(/\[(.*?)\]+/g, (m) => ' '.repeat(m.length)); // remove links 7 | }; 8 | -------------------------------------------------------------------------------- /src/search/search.spec.ts: -------------------------------------------------------------------------------- 1 | import { Indexer } from '../indexing/indexer'; 2 | import Search from './index'; 3 | 4 | const getKeywordsMockFn = jest.fn(); 5 | 6 | jest.mock('../indexing/indexer', () => { 7 | return { 8 | Indexer: jest.fn().mockImplementation(() => { 9 | return { 10 | getKeywords: getKeywordsMockFn, 11 | getDocumentsByKeyword: () => [{}], 12 | }; 13 | }), 14 | }; 15 | }); 16 | 17 | beforeEach(() => { 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | describe('Search class', () => { 22 | it('Highlights single keywords that can be stemmed', () => { 23 | getKeywordsMockFn.mockReturnValue(['search', 'note']); 24 | const text = 'This is a note that I will be use for searching'; 25 | 26 | const indexer = new Indexer(null); 27 | const search = new Search(indexer); 28 | const results = search.find(text); 29 | 30 | expect(results).toEqual([ 31 | { 32 | start: 10, 33 | end: 14, 34 | indexKeyword: 'note', 35 | originalKeyword: 'note', 36 | }, 37 | { 38 | start: 38, 39 | end: 47, 40 | indexKeyword: 'search', 41 | originalKeyword: 'searching', 42 | }, 43 | ]); 44 | }); 45 | 46 | it('Longer keyword matches are always prioritised for highlight', () => { 47 | getKeywordsMockFn.mockReturnValue(['github', 'github fork']); 48 | const text = 'I use GitHub Forks as part of my development flow'; 49 | 50 | const indexer = new Indexer(null); 51 | const search = new Search(indexer); 52 | const results = search.find(text); 53 | 54 | expect(results).toEqual([ 55 | { 56 | start: 6, 57 | end: 18, 58 | indexKeyword: 'github fork', 59 | originalKeyword: 'GitHub Forks', 60 | }, 61 | ]); 62 | }); 63 | 64 | it('Three word keyword is highlighted', () => { 65 | getKeywordsMockFn.mockReturnValue(['shared', 'client', 'record', 'share client record']); 66 | const text = 'Designing a shared client record is a great idea but challenging'; 67 | 68 | const indexer = new Indexer(null); 69 | const search = new Search(indexer); 70 | const results = search.find(text); 71 | 72 | expect(results).toEqual([ 73 | { 74 | start: 12, 75 | end: 32, 76 | indexKeyword: 'share client record', 77 | originalKeyword: 'shared client record', 78 | }, 79 | ]); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/stemmers/index.ts: -------------------------------------------------------------------------------- 1 | import { PorterStemmer } from 'natural'; 2 | 3 | import { WordPunctStemTokenizer } from '../tokenizers'; 4 | 5 | /** 6 | * Stem a given phrase. If the phrase is made up of multiple words, 7 | * the last word in the phrase is the only one that will be stemmed 8 | * @param text input text 9 | * @returns stemmed text 10 | */ 11 | export const stemLastWord = (text: string): string => { 12 | return PorterStemmer.stem(text); 13 | }; 14 | 15 | export const stemPhrase = (text: string): string => { 16 | const tokenizer = new WordPunctStemTokenizer(); 17 | return tokenizer 18 | .tokenize(text) 19 | .map((t) => t.stem) 20 | .join(''); 21 | }; 22 | -------------------------------------------------------------------------------- /src/tokenizers/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { PorterStemmer, NGrams } from 'natural'; 3 | import { Trie } from '@tanishiking/aho-corasick'; 4 | import * as natural from 'natural'; 5 | 6 | import { stemLastWord } from '../stemmers'; 7 | 8 | export type Token = { 9 | index: number; 10 | originalText: string; 11 | originalStart: number; 12 | originalEnd: number; 13 | stem: string; 14 | stemStart: number; 15 | stemEnd: number; 16 | }; 17 | 18 | export class WordPermutationsTokenizer { 19 | private trie: Trie; 20 | 21 | constructor() { 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | const stopWords: string[] = (natural as any).stopwords; 24 | 25 | this.trie = new Trie(stopWords, { 26 | allowOverlaps: false, 27 | onlyWholeWords: true, 28 | caseInsensitive: true, 29 | }); 30 | } 31 | 32 | public tokenize(text: string): string[] { 33 | const tokens = PorterStemmer.tokenizeAndStem(text); // Strip punctuation and stop words, stem remaining words 34 | 35 | if (tokens.length >= 5) { 36 | return [...tokens, ...NGrams.bigrams(tokens).map((tokens) => tokens.join(' '))]; 37 | } 38 | 39 | return this.combinations(tokens, 2, 2); 40 | } 41 | 42 | private combinations(arr: string[], min: number, max: number) { 43 | return [...Array(max).keys()] 44 | .reduce((result) => { 45 | return arr.concat( 46 | result.flatMap((val) => 47 | arr.filter((char) => char !== val).map((char) => `${val} ${char}`) 48 | ) 49 | ); 50 | }, []) 51 | .filter((val) => val.length >= min); 52 | } 53 | } 54 | 55 | export class WordPunctStemTokenizer { 56 | private pattern = /([\s]+|[A-zÀ-ÿ-]+|[0-9._]+|.|!|\?|'|"|:|;|,|-)/i; 57 | 58 | public tokenize(text: string): Token[] { 59 | const tokens = text.split(this.pattern); 60 | return _.chain(tokens).without('').transform(this.stringToTokenAccumulator()).value(); 61 | } 62 | 63 | private stringToTokenAccumulator() { 64 | let originalCharIndex = 0; 65 | let stemCharIndex = 0; 66 | 67 | return (acc: Token[], token: string, index: number) => { 68 | const stemmedToken = stemLastWord(token); 69 | 70 | acc.push({ 71 | index, 72 | originalText: token, 73 | originalStart: originalCharIndex, 74 | originalEnd: originalCharIndex + token.length, 75 | stem: stemmedToken, 76 | stemStart: stemCharIndex, 77 | stemEnd: stemCharIndex + stemmedToken.length, 78 | }); 79 | 80 | originalCharIndex += token.length; 81 | stemCharIndex += stemmedToken.length; 82 | 83 | return acc; 84 | }; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/tokenizers/tokenizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { WordPermutationsTokenizer, WordPunctStemTokenizer } from '.'; 2 | 3 | describe('WordPermutationsTokenizer', () => { 4 | const dataSet = [ 5 | { 6 | description: 'Single word', 7 | sentence: 'John', 8 | expected: ['john'], 9 | }, 10 | { 11 | description: 'Two words with no stop words', 12 | sentence: 'John Doe', 13 | expected: ['john', 'doe', 'john doe', 'doe john'], 14 | }, 15 | { 16 | description: 'Two words (with one stop word at the start)', 17 | sentence: 'The brothers Karamazov', 18 | expected: ['brother', 'karamazov', 'brother karamazov', 'karamazov brother'], 19 | }, 20 | { 21 | description: 'Two words (with stop words throughout the sentence)', 22 | sentence: 'An Officer and a Spy', 23 | expected: ['offic', 'spy', 'offic spy', 'spy offic'], 24 | }, 25 | { 26 | description: 'Three words with no stop words', 27 | sentence: 'GitHub Forking tutorial', 28 | expected: [ 29 | 'github', 30 | 'fork', 31 | 'tutori', 32 | 'github fork', 33 | 'github tutori', 34 | 'fork github', 35 | 'fork tutori', 36 | 'tutori github', 37 | 'tutori fork', 38 | ], 39 | }, 40 | 41 | { 42 | description: 'Five words or more does not generate permutations', 43 | sentence: 'Ten Arguments For Deleting Your Social Media Accounts Right Now', 44 | expected: [ 45 | 'ten', 46 | 'argument', 47 | 'delet', 48 | 'social', 49 | 'media', 50 | 'account', 51 | 'right', 52 | 'ten argument', 53 | 'argument delet', 54 | 'delet social', 55 | 'social media', 56 | 'media account', 57 | 'account right', 58 | ], 59 | }, 60 | ]; 61 | 62 | dataSet.forEach(({ description, sentence, expected }) => { 63 | it(`Tokenize phase permutations (${description})`, () => { 64 | const tokenizer = new WordPermutationsTokenizer(); 65 | const tokens = tokenizer.tokenize(sentence); 66 | 67 | expect(tokens).toEqual(expected); 68 | }); 69 | }); 70 | }); 71 | 72 | describe('WordPunctStemTokenizer', () => { 73 | it('Tokenize and stem a simple phrase', () => { 74 | const sentence = 'The lazy dog jumped over the fence.'; 75 | 76 | const tokenizer = new WordPunctStemTokenizer(); 77 | const tokens = tokenizer.tokenize(sentence); 78 | 79 | expect(tokens.length).toEqual(14); 80 | 81 | expect(tokens[2]).toEqual({ 82 | index: 2, 83 | originalText: 'lazy', 84 | originalStart: 4, 85 | originalEnd: 8, 86 | stem: 'lazi', 87 | stemStart: 4, 88 | stemEnd: 8, 89 | }); 90 | 91 | expect(tokens[6].stem).toEqual('jump'); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/utils/getAliases.spec.ts: -------------------------------------------------------------------------------- 1 | import type { CachedMetadata, FrontMatterCache } from 'obsidian'; 2 | 3 | import { getAliases } from './getAliases'; 4 | 5 | describe('getAliases', () => { 6 | it('Returns an empty array if no frontmatter is defined', () => { 7 | const metadata: CachedMetadata = {}; 8 | const aliases = getAliases(metadata); 9 | expect(aliases).toEqual([]); 10 | }); 11 | 12 | it('Returns an empty array if no aliases are defined', () => { 13 | const metadata: CachedMetadata = { 14 | frontmatter: {} as FrontMatterCache, 15 | }; 16 | 17 | const aliases = getAliases(metadata); 18 | expect(aliases).toEqual([]); 19 | }); 20 | 21 | it('Parses aliases defined as a string split by comma', () => { 22 | const metadata: CachedMetadata = { 23 | frontmatter: { 24 | aliases: 'foo, bar ', 25 | } as unknown as FrontMatterCache, 26 | }; 27 | 28 | const aliases = getAliases(metadata); 29 | expect(aliases).toEqual(['foo', 'bar']); 30 | }); 31 | 32 | it('Parses aliases defined as an array of values', () => { 33 | const metadata: CachedMetadata = { 34 | frontmatter: { 35 | aliases: ['foo', 'bar'], 36 | } as unknown as FrontMatterCache, 37 | }; 38 | 39 | const aliases = getAliases(metadata); 40 | expect(aliases).toEqual(['foo', 'bar']); 41 | }); 42 | 43 | it('Array of aliases is trimmed and processed as strings', () => { 44 | const metadata: CachedMetadata = { 45 | frontmatter: { 46 | aliases: ['foo', 'bar', null, undefined, '', ' ', 200], 47 | } as unknown as FrontMatterCache, 48 | }; 49 | 50 | const aliases = getAliases(metadata); 51 | expect(aliases).toEqual(['foo', 'bar', '200']); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/utils/getAliases.ts: -------------------------------------------------------------------------------- 1 | import type { CachedMetadata } from 'obsidian'; 2 | 3 | export const getAliases = (metadata: CachedMetadata): string[] => { 4 | const frontmatterAliases = metadata?.frontmatter?.['aliases']; 5 | 6 | if (typeof frontmatterAliases === 'string') { 7 | return frontmatterAliases.split(',').map((alias: string) => alias.trim()); 8 | } else if (Array.isArray(frontmatterAliases)) { 9 | return frontmatterAliases 10 | .map((alias) => alias?.toString().trim()) 11 | .filter((alias: string) => alias); 12 | } 13 | 14 | return []; 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "tests/**/*", "webpack.config.ts"], 3 | "exclude": ["node_modules/*"], 4 | "compilerOptions": { 5 | "target": "es5", 6 | "types": ["node", "jest"], 7 | "baseUrl": ".", 8 | "paths": { 9 | "src": ["src/*", "tests/*"], 10 | "~/*": ["src/*"] 11 | }, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "downlevelIteration": true 15 | }, 16 | // Fixes errors when changing `module` to ES in the above compiler options 17 | // See: https://github.com/webpack/webpack-cli/issues/2458#issuecomment-846635277 18 | "ts-node": { 19 | "compilerOptions": { 20 | "module": "commonjs" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.13.8" 3 | } 4 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import pack from './package.json'; 3 | import CopyPlugin from 'copy-webpack-plugin'; 4 | import TerserPlugin from 'terser-webpack-plugin'; 5 | import { type Configuration, DefinePlugin } from 'webpack'; 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const config: Configuration = { 10 | entry: './src/index.ts', 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | filename: '[name].js', 14 | libraryTarget: 'commonjs', 15 | clean: true, 16 | }, 17 | target: 'node', 18 | mode: isProduction ? 'production' : 'development', 19 | devtool: isProduction ? 'source-map' : 'inline-source-map', 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | loader: 'ts-loader', 25 | options: { 26 | transpileOnly: true, 27 | }, 28 | }, 29 | { 30 | test: /\.(svg|njk|html)$/, 31 | type: 'asset/source', 32 | }, 33 | { 34 | test: /\.css$/i, 35 | use: ['style-loader', 'css-loader'], 36 | }, 37 | { 38 | test: /\.(png|jpg|gif)$/i, 39 | type: 'asset/inline', 40 | }, 41 | ], 42 | }, 43 | optimization: { 44 | minimize: isProduction, 45 | minimizer: [ 46 | new TerserPlugin({ 47 | extractComments: false, 48 | minify: TerserPlugin.uglifyJsMinify, 49 | terserOptions: {}, 50 | }), 51 | ], 52 | }, 53 | plugins: [ 54 | new CopyPlugin({ 55 | patterns: [{ from: './manifest.json', to: '.' }], 56 | }), 57 | new DefinePlugin({ 58 | PACKAGE_NAME: JSON.stringify(pack.name), 59 | VERSION: JSON.stringify(pack.version), 60 | PRODUCTION: JSON.stringify(isProduction), 61 | }), 62 | ], 63 | resolve: { 64 | alias: { 65 | '~': path.resolve(__dirname, 'src'), 66 | }, 67 | extensions: ['.ts', '.tsx', '.js'], 68 | mainFields: ['browser', 'module', 'main'], 69 | }, 70 | externals: { 71 | obsidian: 'commonjs2 obsidian', 72 | '@codemirror/view': 'commonjs2 @codemirror/view', 73 | '@codemirror/state': 'commonjs2 @codemirror/state', 74 | '@codemirror/rangeset': 'commonjs2 @codemirror/rangeset', 75 | 'webworker-threads': 'require(webworker-threads)', 76 | }, 77 | }; 78 | 79 | export default config; 80 | --------------------------------------------------------------------------------