├── .hotreload ├── .nvmrc ├── .npmrc ├── assets └── media │ └── demonstration.gif ├── manifest.json ├── test ├── vitest.config.ts ├── jest.config.js ├── testingObjects.ts └── headings.test.ts ├── styles.css ├── src ├── Settings.ts ├── types.ts ├── constants.ts ├── main.ts ├── ManageToc.ts ├── Utils.ts ├── SettingsTab.ts └── validator.ts ├── .vscode ├── tasks.json └── run-vitest.zsh ├── version-bump.mjs ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── test.yml │ └── release_publish.yml ├── versions.json ├── package.json ├── .gitignore ├── esbuild.config.mjs └── README.md /.hotreload: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.9.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /assets/media/demonstration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iLiftALot/insta-toc/HEAD/assets/media/demonstration.gif -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "insta-toc", 3 | "name": "Insta TOC", 4 | "version": "6.4.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "Simultaneously generate, update, and maintain a table of contents for your notes.", 7 | "author": "Nick C.", 8 | "autherUrl": "https://github.com/iLiftALot/insta-toc", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: 'node', 8 | include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 9 | testTransformMode: { 10 | web: ['\\.[jt]sx$'], 11 | }, 12 | coverage: { 13 | reporter: ['text', 'json', 'html'] 14 | } 15 | } 16 | }); 17 | 18 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .fold-toggle { 2 | margin-right: 5px; 3 | background: none; 4 | border: none; 5 | cursor: pointer; 6 | font-size: 18px; 7 | padding: 0; 8 | width: 16px; 9 | height: 20px; 10 | } 11 | 12 | .fold-toggle:focus { 13 | outline: none; 14 | } 15 | 16 | .exclude-chars { 17 | font-size: larger; 18 | font-weight: bolder; 19 | width: 100%; 20 | } 21 | 22 | .setting-title { 23 | display: flex; 24 | justify-content: center; 25 | font-size: xx-large; 26 | } 27 | 28 | .insta-toc-text-info { 29 | width: 33%; 30 | } 31 | 32 | .insta-toc-text-area { 33 | width: 100%; 34 | } 35 | -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | 3 | module.exports = { 4 | testMatch: ["**/*.test.(ts|js)"], 5 | testTimeout: 1000, 6 | testEnvironment: "node", 7 | transform: { 8 | "^.+\\.tsx?$": "ts-jest", // Use ts-jest to transform TS files 9 | }, 10 | transformIgnorePatterns: [ 11 | "/node_modules/(?!obsidian/)", // Transpile the `obsidian` package 12 | ], 13 | extensionsToTreatAsEsm: [".ts"], 14 | moduleNameMapper: { 15 | '^obsidian$': '../node_modules/obsidian/obsidian', 16 | }, 17 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 18 | }; 19 | -------------------------------------------------------------------------------- /src/Settings.ts: -------------------------------------------------------------------------------- 1 | import { DefaultExcludedChars } from "./constants"; 2 | import { BulletType, HeadingLevel, IndentLevel, UpdateDelay } from "./types"; 3 | 4 | export interface InstaTocSettings { 5 | bulletType: BulletType; 6 | indentSize: IndentLevel; 7 | updateDelay: UpdateDelay; 8 | tocTitle: string; 9 | excludedHeadingLevels: HeadingLevel[]; 10 | excludedHeadingText: string[]; 11 | excludedChars: string[]; 12 | } 13 | 14 | export const DEFAULT_SETTINGS: InstaTocSettings = { 15 | bulletType: 'dash', 16 | indentSize: 2, 17 | updateDelay: 2000, 18 | tocTitle: 'Table of Contents', 19 | excludedHeadingLevels: [], 20 | excludedHeadingText: [], 21 | excludedChars: DefaultExcludedChars 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Vitest", 6 | "type": "shell", 7 | "command": "zsh \"${workspaceFolder}/.vscode/run-vitest.zsh\"", 8 | "group": { 9 | "kind": "test", 10 | "isDefault": true 11 | }, 12 | "runOptions": { 13 | "instanceLimit": 1, 14 | "runOn": "default", 15 | "reevaluateOnRerun": true 16 | }, 17 | "icon": { 18 | "id": "go-to-search", 19 | "color": "terminal.ansiCyan" 20 | }, 21 | "presentation": { 22 | "echo": true, 23 | "reveal": "always", 24 | "focus": true, 25 | "panel": "shared", 26 | "showReuseMessage": true, 27 | "clear": false 28 | }, 29 | "problemMatcher": [] 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /.vscode/run-vitest.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # Make local commands available within the testing environment 4 | source ~/.zshrc 5 | 6 | # //.nvm/versions/node//bin/*.exe 7 | NODE_VERSION=$(node -v) 8 | EXEC_PATHS="$HOME/.nvm/versions/node/$NODE_VERSION/bin" 9 | 10 | VITEST_PATH="$EXEC_PATHS/vitest" 11 | NPX_PATH="$EXEC_PATHS/npx" 12 | 13 | CWD=$(pwd) 14 | CONFIG_PATH=$(find "$CWD" -name "vitest.config.ts" \ 15 | -not -path "$CWD/node_modules/*" \ 16 | -not -path "$CWD/.*" \ 17 | -not -path "$CWD/dist*" \ 18 | -not -path "$CWD/src*" \ 19 | -not -path "$CWD/coverage*" \ 20 | -not -path "$CWD/assets*") 21 | 22 | if [ -z "$CONFIG_PATH" ]; then 23 | echo "Vitest config not found!" 24 | exit 1 25 | fi 26 | 27 | #"$VITEST_PATH" --ui --config "$CONFIG_PATH" 28 | #"$NPM_PATH" vitest --ui --config "$CONFIG_PATH" 29 | npx vitest --ui --config "$CONFIG_PATH" 30 | 31 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | // Define paths to the files to update 4 | const packagePath = 'package.json'; 5 | const manifestPath = 'manifest.json'; 6 | const versionsPath = 'versions.json'; 7 | 8 | const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8')); 9 | const manifestJson = JSON.parse(readFileSync(manifestPath, 'utf-8')); 10 | const versionsJson = JSON.parse(readFileSync(versionsPath, 'utf-8')); 11 | 12 | const newVersion = packageJson.version; 13 | const minAppVersion = manifestJson.minAppVersion; 14 | 15 | console.log(`New Version: ${newVersion}\nMinimum App Version: ${minAppVersion}\n`); 16 | 17 | manifestJson.version = newVersion; 18 | writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 4)); 19 | 20 | console.log(`Changed manifest.json version to ${manifestJson.version}\n`); 21 | 22 | versionsJson[newVersion] = minAppVersion; 23 | writeFileSync(versionsPath, JSON.stringify(versionsJson, null, 4)); 24 | 25 | console.log(`Added "${newVersion}: ${minAppVersion}" versions.json:\n${JSON.stringify(versionsJson, null, 4)}`); 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "checkJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "allowSyntheticDefaultImports": true, 14 | "isolatedModules": true, 15 | "strictNullChecks": true, 16 | "resolveJsonModule": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "skipLibCheck": true, 19 | "esModuleInterop": true, 20 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"], 21 | "types": ["node", "obsidian-typings", "jest"], 22 | "paths": { 23 | "obsidian-typings/implementations": [ 24 | "node_modules/obsidian-typings/dist/implementations.d.ts", 25 | "node_modules/obsidian-typings/dist/implementations.cjs" 26 | ] 27 | }, 28 | "outDir": "./dist" 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.d.ts" 33 | ], 34 | "exclude": ["node_modules", "main.js", "dist"] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nick C. 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 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | types: 8 | - closed 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | if: | 14 | github.event_name == 'workflow_dispatch' || ( 15 | github.event_name == 'pull_request' && 16 | github.event.pull_request.merged == true && 17 | github.event.pull_request.base.ref == 'master' 18 | ) 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 1 21 | 22 | strategy: 23 | matrix: 24 | node-version: [22] 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4.2.2 29 | 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v4.1.0 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | 35 | - name: Cache node_modules 36 | id: cache-node-modules 37 | uses: actions/cache@v4.1.2 38 | with: 39 | key: ${{ runner.os }}-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 40 | path: node_modules 41 | 42 | - name: Install dependencies 43 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 44 | run: npm ci 45 | 46 | - name: Run tests 47 | run: npm run test 48 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.0.2": "0.15.0", 5 | "1.0.3": "0.15.0", 6 | "2.0.0": "0.15.0", 7 | "2.0.1": "0.15.0", 8 | "2.0.2": "0.15.0", 9 | "2.0.3": "0.15.0", 10 | "2.0.4": "0.15.0", 11 | "2.0.5": "0.15.0", 12 | "2.0.6": "0.15.0", 13 | "2.0.7": "0.15.0", 14 | "2.0.8": "0.15.0", 15 | "2.0.9": "0.15.0", 16 | "2.0.10": "0.15.0", 17 | "2.0.11": "0.15.0", 18 | "2.0.12": "0.15.0", 19 | "3.0.0": "0.15.0", 20 | "3.0.1": "0.15.0", 21 | "3.0.2": "0.15.0", 22 | "3.0.3": "0.15.0", 23 | "3.1.0": "0.15.0", 24 | "3.1.1": "0.15.0", 25 | "3.1.2": "0.15.0", 26 | "3.1.3": "0.15.0", 27 | "3.1.4": "0.15.0", 28 | "3.1.5": "0.15.0", 29 | "3.1.6": "0.15.0", 30 | "3.1.7": "0.15.0", 31 | "3.1.8": "0.15.0", 32 | "3.2.0": "0.15.0", 33 | "3.2.1": "0.15.0", 34 | "3.2.2": "0.15.0", 35 | "3.2.3": "0.15.0", 36 | "3.2.4": "0.15.0", 37 | "3.2.5": "0.15.0", 38 | "3.2.6": "0.15.0", 39 | "3.2.7": "0.15.0", 40 | "3.2.8": "0.15.0", 41 | "3.2.9": "0.15.0", 42 | "3.3.0": "0.15.0", 43 | "3.4.0": "0.15.0", 44 | "4.0.0": "0.15.0", 45 | "4.1.0": "0.15.0", 46 | "4.1.1": "0.15.0", 47 | "4.1.2": "0.15.0", 48 | "4.1.3": "0.15.0", 49 | "4.2.0": "0.15.0", 50 | "4.2.1": "0.15.0", 51 | "5.0.0": "0.15.0", 52 | "5.0.1": "0.15.0", 53 | "5.0.2": "0.15.0", 54 | "5.0.3": "0.15.0", 55 | "5.0.4": "0.15.0", 56 | "5.0.5": "0.15.0", 57 | "6.0.0": "0.15.0", 58 | "6.1.0": "0.15.0", 59 | "6.1.1": "0.15.0", 60 | "6.1.2": "0.15.0", 61 | "6.2.0": "0.15.0", 62 | "6.3.0": "0.15.0", 63 | "6.3.1": "0.15.0", 64 | "6.3.2": "0.15.0", 65 | "6.4.0": "0.15.0" 66 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CachedMetadata, 3 | Editor, 4 | EditorPosition, 5 | EditorRange, 6 | HeadingCache, 7 | SectionCache 8 | } from "obsidian"; 9 | 10 | // Interface asserts that HeadingCache[] and SectionCache[] 11 | // are not undefined within the CachedMetadata type 12 | export interface ValidCacheType extends CachedMetadata { 13 | headings: HeadingCache[]; 14 | sections: SectionCache[]; 15 | } 16 | 17 | // Type that represents a fully validated Validator instance 18 | export type ValidatedInstaToc = { 19 | metadata: ValidCacheType; 20 | fileHeadings: HeadingCache[]; 21 | instaTocSection: SectionCache; 22 | editor: Editor; 23 | cursorPos: EditorPosition; 24 | tocInsertPos: EditorRange; 25 | localTocSettings: LocalTocSettings; 26 | }; 27 | 28 | export type EditorData = { 29 | editor: Editor | undefined; 30 | cursorPos: EditorPosition | undefined; 31 | }; 32 | 33 | export type BulletType = 'dash' | 'number'; 34 | export type IndentLevel = 2 | 4 | 6 | 8; 35 | export type UpdateDelay = 500 | 1000 36 | | 1500 | 2000 | 2500 | 3000 | 3500 | 4000 37 | | 4500 | 5000 | 5500 | 6000 | 6500 | 7000 38 | | 7500 | 8000 | 8500 | 9000 | 9500 | 10000 39 | 40 | export type TocData = { 41 | fileHeadings: HeadingCache[]; 42 | instaTocSection: SectionCache | undefined; 43 | } 44 | 45 | export type HandledLink = { 46 | contentText: string; 47 | alias: string; 48 | } 49 | export type ListItemContext = { 50 | indent: string; 51 | bullet: string; 52 | navLink: string; 53 | } 54 | 55 | export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; 56 | export type LocalTocStyle = { 57 | listType: BulletType; 58 | } 59 | export type LocalTocTitle = { 60 | name: string; 61 | level: HeadingLevel; 62 | center: boolean; 63 | } 64 | export type LocalTocLevels = { 65 | min: HeadingLevel; 66 | max: HeadingLevel; 67 | } 68 | export interface LocalTocSettings { 69 | title: LocalTocTitle; 70 | exclude: string; 71 | style: LocalTocStyle; 72 | omit: string[]; 73 | levels: LocalTocLevels; 74 | }; 75 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import InstaTocPlugin from "./main"; 2 | import { LocalTocSettings } from "./types"; 3 | import { MergicianOptions } from "mergician"; 4 | 5 | export const instaTocCodeBlockId = 'insta-toc'; 6 | 7 | // Matches 3 groups: indent, bullet, and content 8 | export const listRegex: RegExp = /^(\s*)(-|\d+(?:\.\d+)*|\d\.)\s+(.*)/; 9 | 10 | // Extracts path/link WITH alias from headings with Obsidian wiki links 11 | export const wikiLinkWithAliasRegex: RegExp = /\[\[([^\]]+)\|([^\]]+)\]\]/g; 12 | 13 | // Extracts path/link WITHOUT alias from headings with Obsidian wiki links 14 | export const wikiLinkNoAliasRegex: RegExp = /\[\[([^\]\|]+)\]\]/g; 15 | 16 | // Extracts path/link and alias from headings with regular markdown links 17 | export const markdownLinkRegex: RegExp = /\[([^\]]*)\]\([^)]+\)/g; 18 | 19 | // Replaces tags in headings 20 | export const tagLinkRegex: RegExp = /(#)([/\-_\w][^\s]*)/g; 21 | 22 | // Omit Specific Headings 23 | export const omitHeadingRegex: RegExp = //; 24 | 25 | // Extracts TOC settings 26 | export const localTocSettingsRegex: RegExp = /-{3}\n([\s\S]*)\n-{3}/; 27 | 28 | 29 | export const BulletTypes = { 30 | dash: 'dash', 31 | number: 'number' 32 | } 33 | export const DefaultExcludedChars: string[] = ['*', '_', '`', '==', '~~', '{', '}', '#', '\\']; 34 | 35 | export const mergicianSettings: MergicianOptions = { 36 | onlyCommonKeys: false, 37 | onlyUniversalKeys: false, 38 | skipCommonKeys: false, 39 | skipUniversalKeys: false, 40 | dedupArrays: true, 41 | sortArrays: true 42 | } 43 | 44 | export function getDefaultLocalSettings(): LocalTocSettings { 45 | return { 46 | title: { 47 | name: InstaTocPlugin.getGlobalSetting<'tocTitle'>('tocTitle') ?? 'Table of Contents', 48 | level: 1, 49 | center: false 50 | }, 51 | exclude: "", 52 | style: { 53 | listType: InstaTocPlugin.getGlobalSetting<'bulletType'>('bulletType') ?? BulletTypes.dash 54 | }, 55 | omit: [], 56 | levels: { 57 | min: 1, 58 | max: 6 59 | } 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/release_publish.yml: -------------------------------------------------------------------------------- 1 | name: Release and Publish 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | types: 8 | - closed 9 | workflow_dispatch: 10 | inputs: 11 | version_type: 12 | description: 'Specify version bump type' 13 | required: false 14 | default: 'patch' 15 | type: choice 16 | options: 17 | - patch 18 | - minor 19 | - major 20 | 21 | permissions: 22 | contents: write 23 | 24 | jobs: 25 | release: 26 | if: | 27 | github.event_name == 'workflow_dispatch' || ( 28 | github.event_name == 'pull_request' && 29 | github.event.pull_request.merged == true && 30 | github.event.pull_request.base.ref == 'master' 31 | ) 32 | 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4.2.2 38 | with: 39 | fetch-depth: 0 40 | 41 | - name: Setup Node.js 42 | uses: actions/setup-node@v4.1.0 43 | with: 44 | node-version: '22' 45 | registry-url: 'https://registry.npmjs.org' 46 | 47 | - name: Remove Tag Prefix 48 | run: npm config set tag-version-prefix "" 49 | 50 | - name: Configure Git 51 | run: | 52 | git config user.name "GitHub Actions" 53 | git config user.email "actions@github.com" 54 | 55 | - name: Install dependencies 56 | run: npm ci 57 | 58 | - name: Get Version 59 | id: get_version 60 | run: | 61 | VERSION=$(node -p "require('./package.json').version") 62 | echo "VERSION=$VERSION" >> $GITHUB_ENV 63 | 64 | - name: Build Project 65 | run: npm run build 66 | 67 | #- name: Publish to npm 68 | # env: 69 | # NODE_AUTH_TOKEN: {{ secrets.NPM_TOKEN }} 70 | # run: npm publish 71 | 72 | - name: Create GitHub Release 73 | uses: softprops/action-gh-release@v2.1.0 74 | with: 75 | tag_name: "${{ env.VERSION }}" 76 | name: "${{ env.VERSION }}" 77 | body: | 78 | Automated release of version ${{ env.VERSION }}. 79 | files: | 80 | manifest.json 81 | main.js 82 | styles.css 83 | 84 | - name: Upload Plugin Files 85 | uses: actions/upload-artifact@v4.4.3 86 | with: 87 | name: insta-toc-${{ env.VERSION }} 88 | path: | 89 | manifest.json 90 | main.js 91 | styles.css 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "insta-toc", 3 | "version": "6.4.1", 4 | "description": "Simultaneously generate, update, and maintain a table of contents for your notes in real time.", 5 | "repository": { 6 | "directory": ".", 7 | "type": "git", 8 | "url": "git+https://github.com/iLiftALot/insta-toc.git" 9 | }, 10 | "homepage": "https://github.com/iLiftALot/insta-toc#readme", 11 | "main": "dist/build/main.js", 12 | "scripts": { 13 | "test": "jest --config=test/jest.config.js", 14 | "dev:log": "node esbuild.config.mjs logger && terser -o dist/dev/main.js dist/dev/main.js", 15 | "dev": "node esbuild.config.mjs && terser -o dist/dev/main.js dist/dev/main.js", 16 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && terser -o dist/build/main.js dist/build/main.js", 17 | "bump-version": "node version-bump.mjs", 18 | "upload:patch": "npm version patch --no-git-tag-version && npm run bump-version && npm run build && git add . && VERSION=$(node -p \"require('./package.json').version\") && git commit -m \"Automated update for version $VERSION\"", 19 | "upload:minor": "npm version minor --no-git-tag-version && npm run bump-version && npm run build && git add . && VERSION=$(node -p \"require('./package.json').version\") && git commit -m \"Automated update for version $VERSION\"", 20 | "upload:major": "npm version major --no-git-tag-version && npm run bump-version && npm run build && git add . && VERSION=$(node -p \"require('./package.json').version\") && git commit -m \"Automated update for version $VERSION\"" 21 | }, 22 | "keywords": [ 23 | "obsidian", 24 | "obsidian plugin", 25 | "obsidian-plugin", 26 | "toc", 27 | "table of contents" 28 | ], 29 | "author": "Nick C.", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@jest/globals": "29.7.0", 33 | "@types/jest": "^29.5.14", 34 | "@types/node": "^18.0.0", 35 | "@typescript-eslint/eslint-plugin": "8.13.0", 36 | "@typescript-eslint/parser": "8.13.0", 37 | "@vitest/ui": "^2.1.5", 38 | "builtin-modules": "4.0.0", 39 | "esbuild": "0.24.0", 40 | "jest": "^29.7.0", 41 | "obsidian": "latest", 42 | "obsidian-dev-utils": "latest", 43 | "obsidian-typings": "latest", 44 | "terser": "5.36.0", 45 | "ts-jest": "29.2.5", 46 | "ts-node": "10.9.2", 47 | "tslib": "2.4.0", 48 | "typescript": "5.6.3", 49 | "vitest": "^2.1.5" 50 | }, 51 | "dependencies": { 52 | "mergician": "^2.0.2", 53 | "turndown": "^7.2.0" 54 | } 55 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode/bookmarks.json 124 | .vscode/notes.* 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | 134 | # DS_Store files 135 | *.DS_Store 136 | 137 | # data.json 138 | data.json 139 | 140 | # main.js 141 | main.js -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | import { 7 | existsSync, 8 | writeFileSync, 9 | readFileSync, 10 | copyFileSync 11 | } from "fs"; 12 | 13 | const banner = 14 | `/* 15 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 16 | if you want to view the source, please visit the github repository of this plugin 17 | */ 18 | `; 19 | 20 | const prod = process.argv.includes('production'); 21 | const shouldLog = process.argv.includes('logger'); 22 | let logs = []; 23 | 24 | // Correctly handle the file URL to path conversion 25 | const __filename = fileURLToPath(import.meta.url); 26 | const __dirname = path.dirname(__filename); 27 | 28 | // Load the package.json file 29 | const packageJsonPath = path.join(__dirname, 'package.json'); 30 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); 31 | 32 | const manifestJsonPath = path.join(__dirname, 'manifest.json'); 33 | const manifestJson = JSON.parse(readFileSync(manifestJsonPath, 'utf-8')); 34 | 35 | const dataJsonPath = path.join(__dirname, 'data.json'); 36 | if (!existsSync(dataJsonPath)) { 37 | writeFileSync(dataJsonPath, "{}", 'utf-8'); 38 | } 39 | const dataJson = JSON.parse(readFileSync(dataJsonPath, 'utf-8')); 40 | 41 | // Retrieve the name of the package 42 | const packageName = packageJson.name; 43 | const packageVersion = packageJson.version; 44 | const packageMain = prod ? "dist/build/main.js" : "dist/dev/main.js"; 45 | packageJson.main = packageMain; 46 | writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4), 'utf-8'); 47 | 48 | logs.push('Package Name:', packageName); 49 | logs.push(`Set main.js directory to ${packageJson.main}`); 50 | 51 | const { pluginRoot, vaultRoot, envPath } = { 52 | pluginRoot: __dirname, 53 | vaultRoot: decodeURI(__dirname.replace(/\/\.obsidian.*/, '')) 54 | }; 55 | logs.push(`pluginRoot: ${pluginRoot}\nvaultRoot: ${vaultRoot}`); 56 | 57 | const vaultName = vaultRoot.split('/').pop(); 58 | logs.push(`vaultName: ${vaultName}`); 59 | 60 | const sourcePath = path.resolve(`${pluginRoot}/${packageMain}`); 61 | const targetPath = path.resolve(`${pluginRoot}/main.js`); 62 | 63 | logs.push(`Source Path: ${sourcePath}`); 64 | logs.push(`Target Path: ${targetPath}`); 65 | 66 | const context = await esbuild.context({ 67 | banner: { 68 | js: banner, 69 | }, 70 | entryPoints: ["src/main.ts"], 71 | bundle: true, 72 | external: [ 73 | "obsidian", 74 | "electron", 75 | "@codemirror/autocomplete", 76 | "@codemirror/collab", 77 | "@codemirror/commands", 78 | "@codemirror/language", 79 | "@codemirror/lint", 80 | "@codemirror/search", 81 | "@codemirror/state", 82 | "@codemirror/view", 83 | "@lezer/common", 84 | "@lezer/highlight", 85 | "@lezer/lr", 86 | ...builtins 87 | ], 88 | platform: "node", 89 | format: "cjs", 90 | target: "es2021", 91 | logLevel: "info", 92 | sourcemap: prod ? false : "inline", 93 | treeShaking: true, 94 | outfile: packageMain, 95 | minify: prod 96 | }).catch((error) => { 97 | console.error(error); 98 | process.exit(1); 99 | }); 100 | 101 | function copyMainJs() { 102 | try { 103 | copyFileSync(sourcePath, targetPath); 104 | logs.push(`Copied file: ${sourcePath} -> ${targetPath}`); 105 | 106 | logs = logs.join('\n'); 107 | if (shouldLog) console.log(logs); 108 | } catch (error) { 109 | console.error(`Error copying main.js: ${error}\nLogs:\n${logs.join('\n')}`); 110 | process.exit(1); 111 | } 112 | } 113 | 114 | if (prod) { 115 | await context.rebuild(); 116 | copyMainJs(); 117 | await context.dispose(); 118 | } else { 119 | copyMainJs(); 120 | await context.watch(); 121 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | CachedMetadata, 4 | Debouncer, 5 | EventRef, 6 | MarkdownPostProcessorContext, 7 | MarkdownRenderer, 8 | Plugin, 9 | PluginManifest, 10 | TFile, 11 | debounce 12 | } from 'obsidian'; 13 | import { mergicianSettings } from './constants'; 14 | import { mergician } from 'mergician'; 15 | import { InstaTocSettings, DEFAULT_SETTINGS } from './Settings'; 16 | import { SettingTab } from './SettingsTab'; 17 | import { ManageToc } from './ManageToc'; 18 | import { configureRenderedIndent, getEditorData, handleCodeblockListItem } from './Utils'; 19 | import { listRegex, localTocSettingsRegex } from './constants'; 20 | import { EditorData } from './types'; 21 | import { Validator } from './validator'; 22 | 23 | 24 | export default class InstaTocPlugin extends Plugin { 25 | public app: App; 26 | public settings: InstaTocSettings; 27 | private validator: Validator | undefined; 28 | private modifyEventRef: EventRef | undefined; 29 | private debouncer: Debouncer<[fileCache: CachedMetadata], void>; 30 | 31 | // Flags to maintain state with updates 32 | public hasTocBlock = true; 33 | 34 | public getDelay = () => this.settings.updateDelay; 35 | 36 | constructor(app: App, manifest: PluginManifest) { 37 | super(app, manifest); 38 | this.app = app; 39 | } 40 | 41 | async onload(): Promise { 42 | console.log(`Loading Insta TOC Plugin`); 43 | 44 | await this.loadSettings(); 45 | this.addSettingTab(new SettingTab(this.app, this)); 46 | 47 | // Custom codeblock processor for the insta-toc codeblock 48 | this.registerMarkdownCodeBlockProcessor( 49 | "insta-toc", 50 | async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): Promise => { 51 | if (!this.hasTocBlock) { 52 | this.hasTocBlock = true; 53 | } 54 | 55 | const pathWithFileExtension: string = ctx.sourcePath; // Includes .md 56 | const filePath: string = pathWithFileExtension.substring(0, pathWithFileExtension.lastIndexOf(".")); 57 | const file: TFile = this.app.vault.getAbstractFileByPath(pathWithFileExtension) as TFile; 58 | // TOC codeblock content 59 | const lines: string[] = source 60 | // Process only the ToC content without local settings 61 | .replace(localTocSettingsRegex, '') 62 | .split('\n'); 63 | 64 | const headingLevels: number[] = []; // To store heading levels corresponding to each line 65 | 66 | // Process the codeblock text by converting each line into a markdown link list item 67 | const processedSource: string = lines.map((line) => { 68 | const match: RegExpMatchArray | null = line.match(listRegex); 69 | if (!match) return line; 70 | 71 | const { indent, bullet, navLink } = handleCodeblockListItem(this.app, this, file, match, filePath); 72 | 73 | // Calculate heading level based on indentation 74 | const indentLevel = Math.floor(indent.length / 4); // Each indent level represents one heading level increment 75 | const headingLevel: number = indentLevel + 1; // H1 corresponds to no indentation 76 | headingLevels.push(headingLevel); 77 | 78 | return `${indent}${bullet} ${navLink}`; 79 | }).join('\n'); 80 | 81 | // Now render the markdown 82 | await MarkdownRenderer.render(this.app, processedSource, el, pathWithFileExtension, this); 83 | 84 | // Configure indentation once rendered 85 | configureRenderedIndent(el, headingLevels, this.settings.indentSize); 86 | } 87 | ); 88 | 89 | this.registerEvent( 90 | // Reset with new files to fix no detection on file open 91 | this.app.workspace.on("file-open", () => this.hasTocBlock = true) 92 | ); 93 | 94 | this.updateModifyEventListener(); 95 | } 96 | 97 | onunload(): void { 98 | console.log(`Insta TOC Plugin Unloaded.`); 99 | } 100 | 101 | async loadSettings(): Promise { 102 | let mergedSettings: InstaTocSettings = DEFAULT_SETTINGS; 103 | const settingsData: InstaTocSettings = await this.loadData(); 104 | 105 | if (settingsData) { 106 | mergedSettings = mergician(mergicianSettings)(DEFAULT_SETTINGS, settingsData); 107 | } 108 | 109 | this.settings = mergedSettings; 110 | } 111 | 112 | async saveSettings(): Promise { 113 | await this.saveData(this.settings); 114 | } 115 | 116 | // Dynamically update the debounce delay for ToC updates 117 | public updateModifyEventListener(): void { 118 | if (this.modifyEventRef) { 119 | // Unregister the previous event listener 120 | this.app.metadataCache.offref(this.modifyEventRef); 121 | } 122 | 123 | this.setDebouncer(); 124 | 125 | // Register the new event listener with the updated debounce delay 126 | this.modifyEventRef = this.app.metadataCache.on( 127 | "changed", // file cache (containing heading cache) has been updated 128 | (file: TFile, data: string, cache: CachedMetadata) => { 129 | if (!this.hasTocBlock) return; 130 | 131 | this.debouncer(cache); 132 | } 133 | ); 134 | 135 | this.registerEvent(this.modifyEventRef); 136 | } 137 | 138 | // Needed for dynamically setting the debounce delay 139 | public setDebouncer(): void { 140 | this.debouncer = debounce( 141 | (fileCache: CachedMetadata) => { 142 | const { editor, cursorPos }: EditorData = getEditorData(this.app); 143 | 144 | if (!editor || !cursorPos) return; 145 | 146 | // Reuse and update the existing validator instance if it exists 147 | if (this.validator) { 148 | this.validator.update(this, fileCache, editor, cursorPos); 149 | } else { 150 | this.validator = new Validator(this, fileCache, editor, cursorPos); 151 | } 152 | 153 | const isValid: boolean = this.validator.isValid(); 154 | 155 | if (isValid) { 156 | // Handle all active file changes for the insta-toc plaintext content 157 | new ManageToc(this, this.validator); 158 | } 159 | }, this.settings.updateDelay, false 160 | ); 161 | } 162 | 163 | public static getGlobalSetting(key: K): InstaTocSettings[K] { 164 | const plugin = (window as any).app.plugins.getPlugin('insta-toc') as InstaTocPlugin; 165 | const settings = plugin?.settings as InstaTocSettings; 166 | 167 | return settings[key]; 168 | } 169 | } -------------------------------------------------------------------------------- /src/ManageToc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | EditorRange, 4 | HeadingCache, 5 | stringifyYaml 6 | } from 'obsidian'; 7 | import InstaTocPlugin from './main'; 8 | import { instaTocCodeBlockId } from './constants'; 9 | import { Validator } from './validator'; 10 | 11 | export class ManageToc { 12 | private plugin: InstaTocPlugin; 13 | private validator: Validator; 14 | private headingLevelStack: number[]; 15 | 16 | constructor( 17 | plugin: InstaTocPlugin, 18 | validator: Validator 19 | ) { 20 | this.plugin = plugin; 21 | this.validator = validator; 22 | this.headingLevelStack = []; 23 | 24 | this.updateAutoToc(); 25 | } 26 | 27 | // Determine the correct indentation level 28 | private getIndentationLevel(headingLevel: number): number { 29 | // Pop from the stack until we find a heading level less than the current 30 | while ( 31 | this.headingLevelStack.length > 0 && // Avoid indentation for the first heading 32 | headingLevel <= this.headingLevelStack[this.headingLevelStack.length - 1] 33 | ) { 34 | this.headingLevelStack.pop(); 35 | } 36 | this.headingLevelStack.push(headingLevel); 37 | 38 | const currentIndentLevel = this.headingLevelStack.length - 1; 39 | 40 | return currentIndentLevel; 41 | } 42 | 43 | // Generates a new insta-toc codeblock with number-type bullets 44 | private generateNumberedToc(): string { 45 | const tocHeadingRefs: string[] = []; 46 | const levelNumbers: { [level: number]: number } = {}; 47 | const fileHeadings: HeadingCache[] = this.validator.fileHeadings; 48 | 49 | for (const headingCache of fileHeadings) { 50 | const headingLevel = headingCache.level; 51 | const headingText = headingCache.heading; 52 | 53 | if (headingText.length === 0) continue; 54 | 55 | const currentIndentLevel = this.getIndentationLevel(headingLevel); 56 | 57 | // Initialize numbering for this level if not already 58 | if (!levelNumbers[currentIndentLevel]) { 59 | levelNumbers[currentIndentLevel] = 0; 60 | } 61 | 62 | // Reset numbering for deeper levels 63 | for (let i = currentIndentLevel + 1; i <= 6; i++) { 64 | levelNumbers[i] = 0; 65 | } 66 | 67 | // Increment the numbering at the current level 68 | levelNumbers[currentIndentLevel]++; 69 | 70 | const indent = ' '.repeat(currentIndentLevel * 4); 71 | const bullet = levelNumbers[currentIndentLevel].toString(); 72 | const tocHeadingRef = `${indent}${bullet}. ${headingText}`; 73 | 74 | tocHeadingRefs.push(tocHeadingRef); 75 | } 76 | 77 | const tocContent: string = `${ 78 | instaTocCodeBlockId 79 | }\n---\n${ 80 | stringifyYaml(this.validator.localTocSettings) 81 | }---\n\n${ 82 | '#'.repeat(this.validator.localTocSettings.title.level) 83 | } ${ 84 | this.validator.localTocSettings.title.center 85 | ? '
' + this.validator.localTocSettings.title.name + '
' 86 | : this.validator.localTocSettings.title.name 87 | }\n\n${ 88 | tocHeadingRefs.join('\n') 89 | }`; 90 | 91 | return `\`\`\`${tocContent}\n\`\`\``; 92 | } 93 | 94 | // Generates a new insta-toc codeblock with normal dash-type bullets 95 | private generateNormalToc(): string { 96 | const tocHeadingRefs: string[] = []; 97 | const fileHeadings: HeadingCache[] = this.validator.fileHeadings; 98 | 99 | for (const headingCache of fileHeadings) { 100 | const headingLevel: number = headingCache.level; 101 | const headingText: string = headingCache.heading; 102 | 103 | if (headingText.length === 0) continue; 104 | 105 | const currentIndentLevel = this.getIndentationLevel(headingLevel); 106 | 107 | // Calculate the indentation based on the current indentation level 108 | const indent: string = ' '.repeat(currentIndentLevel * 4); 109 | const tocHeadingRef = `${indent}- ${headingText}`; 110 | 111 | tocHeadingRefs.push(tocHeadingRef); 112 | } 113 | 114 | const tocContent: string = `${ 115 | instaTocCodeBlockId 116 | }\n---\n${ 117 | stringifyYaml(this.validator.localTocSettings) 118 | }---\n\n${ 119 | '#'.repeat(this.validator.localTocSettings.title.level) 120 | } ${ 121 | this.validator.localTocSettings.title.center 122 | ? '
' + this.validator.localTocSettings.title.name + '
' 123 | : this.validator.localTocSettings.title.name 124 | }\n\n${ 125 | tocHeadingRefs.join('\n') 126 | }`; 127 | 128 | return `\`\`\`${tocContent}\n\`\`\``; 129 | } 130 | 131 | // Workaround for the smart list number order bug 132 | private insertTocBlock( 133 | newTocBlock: string, 134 | tocInsertRange: EditorRange 135 | ): void { 136 | // Replace the old TOC with the updated TOC 137 | this.validator.editor.replaceRange( 138 | newTocBlock, tocInsertRange.from, tocInsertRange.to 139 | ); 140 | } 141 | 142 | // Dynamically update the TOC 143 | private updateAutoToc(): void { 144 | const tocInsertRange: EditorRange = this.validator.tocInsertPos; 145 | // Determine if local settings override global 146 | const decisionMaker: string = this.plugin.settings.bulletType !== this.validator.localTocSettings.style.listType 147 | ? this.validator.localTocSettings.style.listType 148 | : this.plugin.settings.bulletType; 149 | 150 | let newTocBlock: string; 151 | switch(decisionMaker) { 152 | case "dash": 153 | newTocBlock = this.generateNormalToc(); 154 | break; 155 | case "number": 156 | newTocBlock = this.generateNumberedToc(); 157 | break; 158 | default: 159 | newTocBlock = this.generateNormalToc(); 160 | break; 161 | } 162 | 163 | // Workaround for the smart list number order bug 164 | this.insertTocBlock(newTocBlock, tocInsertRange); 165 | } 166 | } -------------------------------------------------------------------------------- /test/testingObjects.ts: -------------------------------------------------------------------------------- 1 | import { HeadingCache, Pos } from "obsidian"; 2 | import TurndownService from "turndown"; 3 | 4 | export const defaultPosition: Pos = { 5 | start: { line: 0, col: 0, offset: 0 }, 6 | end: { line: 0, col: 0, offset: 0 } 7 | }; 8 | 9 | export const testStandardHeadings: HeadingCache[] = [ 10 | { heading: 'Title 1 Level 1', level: 1, position: defaultPosition }, 11 | { heading: 'Title 1 Level 2', level: 2, position: defaultPosition }, 12 | { heading: 'Title 1 Level 3', level: 3, position: defaultPosition }, 13 | { heading: 'Title 1 Level 4', level: 4, position: defaultPosition }, 14 | { heading: 'Title 1 Level 5', level: 5, position: defaultPosition }, 15 | { heading: 'Title 1 Level 6', level: 6, position: defaultPosition } 16 | ]; 17 | 18 | export const testHeadingsWithoutFirstLevel: HeadingCache[] = [ 19 | { heading: 'Title 1 Level 2', level: 2, position: defaultPosition }, 20 | { heading: 'Title 1 Level 3', level: 3, position: defaultPosition }, 21 | { heading: 'Title 1 Level 4', level: 4, position: defaultPosition }, 22 | { heading: 'Title 1 Level 5', level: 5, position: defaultPosition }, 23 | { heading: 'Title 1 Level 6', level: 6, position: defaultPosition } 24 | ]; 25 | 26 | export const testHeadingsMixed: HeadingCache[] = [ 27 | { heading: 'Title 1 Level 4', level: 4, position: defaultPosition }, 28 | { heading: 'Title 1 Level 1', level: 1, position: defaultPosition }, 29 | { heading: 'Title 1 Level 6', level: 6, position: defaultPosition }, 30 | { heading: 'Title 1 Level 2', level: 2, position: defaultPosition }, 31 | { heading: 'Title 2 Level 2', level: 2, position: defaultPosition }, 32 | { heading: 'Title 1 Level 3', level: 3, position: defaultPosition } 33 | ]; 34 | 35 | export const testHeadingsWithSpecialChars: HeadingCache[] = [ 36 | { 37 | heading: 'Title 1 `level 1` {with special chars}, **bold**, _italic_, #a-tag, ==highlighted== and ~~strikethrough~~ text', 38 | level: 1, 39 | position: defaultPosition 40 | }, 41 | { 42 | heading: 'Title 1 level 2 with HTML', 43 | level: 2, 44 | position: defaultPosition 45 | }, 46 | { 47 | heading: 'Title 1 level 3 [[wikilink1]] [[wikilink2|wikitext2]] [mdlink](https://mdurl)', 48 | level: 3, 49 | position: defaultPosition 50 | }, 51 | { 52 | heading: 'Title 1 level 4 [[wikilink1]] [[wikilink2|wikitext2]] [mdlink1](https://mdurl) [[wikilink3]] [[wikilink4|wikitext3]] [mdlink2](https://mdurl)', 53 | level: 4, 54 | position: defaultPosition 55 | }, 56 | { 57 | heading: 'Title 1 level 5 ', 58 | level: 5, 59 | position: defaultPosition 60 | } 61 | ]; 62 | 63 | 64 | export enum TestNames { 65 | "testStandardHeadings", 66 | "testHeadingsWithoutFirstLevel", 67 | "testHeadingsMixed", 68 | "testHeadingsWithSpecialChars" 69 | } 70 | export type TestName = keyof typeof TestNames; 71 | export type ContextResult = { 72 | initialHeadings: string[]; 73 | formattedHeadings: string[]; 74 | finalResult: string; 75 | } 76 | export type Context = Record; 77 | 78 | export const initialStandardHeadings = Object.values(testStandardHeadings) 79 | .map((cache: HeadingCache) => cache.heading); 80 | export const initialHeadingsWithoutFirstLevel = Object.values(testHeadingsWithoutFirstLevel) 81 | .map((cache: HeadingCache) => cache.heading); 82 | export const initialHeadingsMixed = Object.values(testHeadingsMixed) 83 | .map((cache: HeadingCache) => cache.heading); 84 | export const initialHeadingsWithSpecialChars = Object.values(testHeadingsWithSpecialChars) 85 | .map((cache: HeadingCache) => cache.heading); 86 | 87 | export const TEST_DEFAULT_SETTINGS = { 88 | bulletType: 'dash', 89 | indentSize: 4, 90 | updateDelay: 2000, 91 | excludedChars: ['*', '_', '`', '==', '~~', '{', '}', '#', '\\'] 92 | } 93 | 94 | // Extracts path/link WITH alias from headings with Obsidian wiki links 95 | export const testWikiLinkWithAliasRegex = /\[\[([^\]]+)\|([^\]]+)\]\]/g; 96 | // Extracts path/link WITHOUT alias from headings with Obsidian wiki links 97 | export const testWikiLinkNoAliasRegex = /\[\[([^\]\|]+)\]\]/g; 98 | // Extracts path/link and alias from headings with regular markdown links 99 | export const testMarkdownLinkRegex = /\[([^\]]+)\]\([^)]+\)/g; 100 | // Replaces tags in headings 101 | export const testTagLinkRegex: RegExp = /(#)([/\-_\w][^\s]*)/g; 102 | // Omit Specific Headings 103 | export const testOmitHeadingRegex: RegExp = //; 104 | 105 | export function testHandleLinks(fileName: string, content: string, indentation: number) { 106 | let [contentText, alias] = [content, content]; 107 | 108 | // Process Obsidian wiki links with alias 109 | contentText = contentText.replace(testWikiLinkWithAliasRegex, (match, refPath, refAlias) => { 110 | // Text including [[wikilink|wikitext]] -> Text including wikilink wikitext 111 | return `${refPath} ${refAlias}`; 112 | }); 113 | alias = alias.replace(testWikiLinkWithAliasRegex, (match, refPath, refAlias) => { 114 | // [[wikilink|wikitext]] -> wikitext 115 | return refAlias; 116 | }); 117 | 118 | // Process Obsidian wiki links without alias 119 | contentText = contentText.replace(testWikiLinkNoAliasRegex, (match, refPath) => { 120 | // Text including [[wikilink]] -> Text including wikilink 121 | // OR 122 | // Text including [[path/to/wikilink]] -> Text including wikilink 123 | refPath = refPath.split('/').pop() ?? refPath; 124 | return refPath; 125 | }); 126 | alias = alias.replace(testWikiLinkNoAliasRegex, (match, refPath) => { 127 | // [[wikilink]] -> wikilink 128 | // OR 129 | // [[path/to/wikilink]] -> wikilink 130 | refPath = refPath.split('/').pop() ?? refPath; 131 | return refPath; 132 | }); 133 | 134 | // Process markdown links 135 | contentText = contentText.replace(testMarkdownLinkRegex, (match, refAlias) => { 136 | // Text including [Link](https://www.link.com) -> Text including [Link](https://www.link.com) 137 | return match; 138 | }); 139 | alias = alias.replace(testMarkdownLinkRegex, (match, refAlias) => { 140 | // [Link](https://www.link.com) -> Link 141 | return refAlias; 142 | }); 143 | 144 | // Final clean and format for tags and HTML 145 | contentText = contentText.replace(testTagLinkRegex, (match, symbol, tag) => { // Remove any tags 146 | // Text including #a-tag -> Text including a-tag 147 | return tag; 148 | }); 149 | alias = testCleanAlias(alias); // Process HTML and exluded characters 150 | 151 | return `${' '.repeat((indentation < 0 ? 0 : indentation) * 4)}- [[${fileName}#${contentText}|${alias}]]`; 152 | } 153 | 154 | // Strip the alias of specified excluded characters and convert HTML to markdown 155 | export function testCleanAlias(aliasText: string) { 156 | const turndownService = new TurndownService({ 157 | "emDelimiter": "_", 158 | "strongDelimiter": "**" 159 | }); 160 | const excludedChars = TEST_DEFAULT_SETTINGS.excludedChars; 161 | let alias = turndownService.turndown(aliasText); 162 | 163 | // Replace all specified excluded characters 164 | for (const char of excludedChars) alias = alias.replaceAll(char, ''); 165 | 166 | return alias; 167 | } 168 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TFile, 3 | App, 4 | htmlToMarkdown, 5 | Editor, 6 | EditorPosition 7 | } from "obsidian"; 8 | import { 9 | markdownLinkRegex, 10 | tagLinkRegex, 11 | wikiLinkNoAliasRegex, 12 | wikiLinkWithAliasRegex 13 | } from "./constants"; 14 | import { 15 | EditorData, 16 | HandledLink, 17 | HeadingLevel, 18 | IndentLevel, 19 | ListItemContext 20 | } from "./types"; 21 | import InstaTocPlugin from "./main"; 22 | 23 | 24 | // Handle the codeblock list item and return the indent level and navigation link 25 | export function handleCodeblockListItem( 26 | app: App, 27 | plugin: InstaTocPlugin, 28 | file: TFile, 29 | listItemMatch: RegExpMatchArray, 30 | filePath: string 31 | ): ListItemContext { 32 | let [, indent, bullet, content]: RegExpMatchArray = listItemMatch; 33 | let { contentText, alias } = handleLinks(plugin, content); 34 | 35 | const navLink: string = app.fileManager.generateMarkdownLink( 36 | file, filePath, `#${contentText}`, alias 37 | ); 38 | 39 | return { indent, bullet, navLink }; 40 | } 41 | 42 | // Handle links in the content and alias of a list item 43 | export function handleLinks(plugin: InstaTocPlugin, content: string): HandledLink { 44 | let [contentText, alias]: string[] = [content, content]; 45 | 46 | // Process Obsidian wiki links with alias 47 | contentText = contentText.replace(wikiLinkWithAliasRegex, (match, refPath, refAlias) => { 48 | // Text including [[wikilink|wikitext]] -> Text including wikilink wikitext 49 | return `${refPath} ${refAlias}`; 50 | }); 51 | alias = alias.replace(wikiLinkWithAliasRegex, (match, refPath, refAlias) => { 52 | // [[wikilink|wikitext]] -> wikitext 53 | return refAlias; 54 | }); 55 | 56 | // Process Obsidian wiki links without alias 57 | contentText = contentText.replace(wikiLinkNoAliasRegex, (match, refPath) => { 58 | // Text including [[wikilink]] -> Text including wikilink 59 | // OR 60 | // Text including [[path/to/wikilink]] -> Text including wikilink 61 | return refPath.split('/').pop() ?? refPath; 62 | }); 63 | alias = alias.replace(wikiLinkNoAliasRegex, (match, refPath) => { 64 | // [[wikilink]] -> wikilink 65 | // OR 66 | // [[path/to/wikilink]] -> wikilink 67 | return refPath.split('/').pop() ?? refPath; 68 | }); 69 | 70 | // Process markdown links 71 | contentText = contentText.replace(markdownLinkRegex, (match, refAlias) => { 72 | // Text including [Link](https://www.link.com) -> Text including [Link](https://www.link.com) 73 | return match; 74 | }); 75 | alias = alias.replace(markdownLinkRegex, (match, refAlias) => { 76 | // [Link](https://www.link.com) -> Link 77 | return refAlias; 78 | }); 79 | 80 | // Clean up tags 81 | contentText = contentText.replace(tagLinkRegex, (match, symbol, tag) => { // Remove any tags 82 | // Text including #a-tag -> Text including a-tag 83 | return tag; 84 | }); 85 | // Process HTML and exluded characters 86 | alias = cleanAlias(alias, plugin); 87 | 88 | return { contentText, alias }; 89 | } 90 | 91 | // Strip the alias of specified excluded characters and convert HTML to markdown 92 | export function cleanAlias(aliasText: string, plugin?: InstaTocPlugin, exclChars?: string[]): string { 93 | const excludedChars = (plugin ? plugin.settings.excludedChars : exclChars) ?? []; 94 | let alias: string = htmlToMarkdown(aliasText); // Convert any possible HTML to markdown 95 | 96 | // Replace all specified excluded characters 97 | for (const char of excludedChars) alias = alias.replaceAll(char, ''); 98 | 99 | return alias; 100 | } 101 | 102 | // Configure indentation for the insta-toc code block HTML element, post-render 103 | export function configureRenderedIndent( 104 | el: HTMLElement, 105 | headingLevels: number[], 106 | indentSize: IndentLevel 107 | ): void { 108 | const listItems: NodeListOf = el.querySelectorAll('li'); 109 | 110 | listItems.forEach((listItem: HTMLLIElement, index: number) => { 111 | const headingLevel: number = headingLevels[index]; 112 | 113 | // Only adjust indentation for headings beyond H1 (headingLevel > 1) 114 | if (headingLevel > 1) { 115 | listItem.style.marginInlineStart = `${indentSize * 10}px`; 116 | } 117 | 118 | const subList: HTMLUListElement | HTMLOListElement | null = listItem.querySelector('ul, ol'); 119 | 120 | if (subList) { 121 | // List item has children 122 | const toggleButton: HTMLButtonElement = document.createElement('button'); 123 | toggleButton.textContent = '▾'; // Down arrow 124 | toggleButton.classList.add('fold-toggle'); 125 | 126 | // Event listener to toggle visibility 127 | toggleButton.addEventListener('click', () => { 128 | if (subList.style.display === 'none') { 129 | subList.style.display = ''; 130 | toggleButton.textContent = '▾'; 131 | } else { 132 | subList.style.display = 'none'; 133 | toggleButton.textContent = '▸'; 134 | } 135 | }); 136 | 137 | listItem.prepend(toggleButton); 138 | } 139 | }); 140 | } 141 | 142 | // Get the editor and cursor position 143 | export function getEditorData(app: App): EditorData { 144 | const editor: Editor | undefined = app.workspace.activeEditor?.editor 145 | const cursorPos: EditorPosition | undefined = editor?.getCursor(); 146 | 147 | return { editor, cursorPos } 148 | } 149 | 150 | // Escape special characters in a string for use in a regular expression 151 | export function escapeRegExp(string: string): string { 152 | return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); 153 | } 154 | 155 | // Check if a string is a regex pattern 156 | export function isRegexPattern(string: string): boolean { 157 | // Checks if the string starts and ends with '/' 158 | return /^\/.*\/$/.test(string); 159 | } 160 | 161 | // Check if a string is a valid heading level 162 | export function isHeadingLevel(value: any): value is HeadingLevel { 163 | return [1, 2, 3, 4, 5, 6].includes(value); 164 | } 165 | 166 | // Check if a value is an object that can be merged 167 | function isMergeableObject(value: any): boolean { 168 | return value && typeof value === 'object' && !Array.isArray(value); 169 | } 170 | 171 | // Deep merge two objects 172 | export function deepMerge(target: Partial, source: Partial, dedupeArrays = true): T { 173 | if (isMergeableObject(target) && isMergeableObject(source)) { 174 | for (const key of Object.keys(source) as Array) { 175 | const targetValue = target[key]; 176 | const sourceValue = source[key]; 177 | 178 | if (isMergeableObject(sourceValue)) { 179 | if (!targetValue) { 180 | (target as any)[key] = {}; 181 | } 182 | 183 | deepMerge(target[key] as any, sourceValue as any); 184 | } else if (Array.isArray(targetValue) && Array.isArray(sourceValue)) { 185 | if (dedupeArrays) { 186 | (target as any)[key] = [...new Set(targetValue.concat(sourceValue))]; 187 | } else { 188 | (target as any)[key] = sourceValue; 189 | } 190 | } else { 191 | (target as any)[key] = sourceValue; 192 | } 193 | } 194 | } 195 | 196 | return target as T; 197 | } 198 | -------------------------------------------------------------------------------- /src/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | DropdownComponent, 4 | PluginSettingTab, 5 | Setting, 6 | SliderComponent, 7 | TextAreaComponent, 8 | TextComponent 9 | } from 'obsidian'; 10 | import InstaToc from './main'; 11 | import { BulletTypes, DefaultExcludedChars } from './constants'; 12 | import { BulletType, HeadingLevel, IndentLevel, UpdateDelay } from "./types"; 13 | 14 | 15 | export class SettingTab extends PluginSettingTab { 16 | plugin: InstaToc; 17 | 18 | constructor(app: App, plugin: InstaToc) { 19 | super(app, plugin); 20 | this.plugin = plugin; 21 | } 22 | 23 | display(): void { 24 | const { containerEl } = this; 25 | 26 | containerEl.empty(); 27 | 28 | const tabTitle = new Setting(containerEl) 29 | .setHeading() 30 | .setName('Insta ToC Global Settings'); 31 | tabTitle.nameEl.classList.add('setting-title'); 32 | tabTitle.controlEl.remove(); 33 | 34 | // Global list bullet style 35 | new Setting(containerEl) 36 | .setName('List bullet style') 37 | .setDesc('Select the global list bullet type.') 38 | .addDropdown((component: DropdownComponent) => 39 | component 40 | .addOptions(BulletTypes) 41 | .setValue(this.plugin.settings.bulletType) 42 | .onChange(async (value: BulletType) => { 43 | this.plugin.settings.bulletType = value; 44 | await this.plugin.saveSettings(); 45 | }) 46 | ); 47 | 48 | // Global indentation width 49 | new Setting(containerEl) 50 | .setName('Indentation width') 51 | .setDesc('Select the global indentation size.') 52 | .addSlider((component: SliderComponent) => 53 | component 54 | .setLimits(2, 8, 2) 55 | .setDynamicTooltip() 56 | .setInstant(true) 57 | .setValue(this.plugin.settings.indentSize) 58 | .onChange(async (value: IndentLevel) => { 59 | this.plugin.settings.indentSize = value; 60 | await this.plugin.saveSettings(); 61 | }) 62 | ); 63 | 64 | // Update delay 65 | new Setting(containerEl) 66 | .setName('Update delay') 67 | .setDesc('The delay for each ToC update.') 68 | .addSlider((component: SliderComponent) => { 69 | component 70 | .setLimits(500, 10000, 500) 71 | .setDynamicTooltip() 72 | .setInstant(true) 73 | .setValue(this.plugin.settings.updateDelay) 74 | .onChange(async (value: UpdateDelay) => { 75 | this.plugin.settings.updateDelay = value; 76 | await this.plugin.saveSettings(); 77 | this.plugin.updateModifyEventListener(); 78 | }); 79 | }); 80 | 81 | // Global ToC title 82 | new Setting(containerEl) 83 | .setName('ToC Title') 84 | .setDesc('The global title for the Table of Contents.') 85 | .addText((component: TextComponent) => { 86 | component 87 | .setValue(this.plugin.settings.tocTitle) 88 | .onChange(async (value: string) => { 89 | this.plugin.settings.tocTitle = value; 90 | await this.plugin.saveSettings(); 91 | }); 92 | 93 | component.inputEl.placeholder = 'Table of Contents'; 94 | }).infoEl.classList.add('insta-toc-text-info'); 95 | 96 | // Global excluded heading text 97 | new Setting(containerEl) 98 | .setName('Excluded heading text') 99 | .setDesc('Comma-separated list of headings to exclude globally within the Table of Contents.') 100 | .addTextArea((component: TextAreaComponent) => { 101 | component.setValue(this.plugin.settings.excludedHeadingText.join(',')); 102 | 103 | // Update settings when the text area loses focus 104 | component.inputEl.addEventListener('blur', async () => { 105 | const textValue = component.getValue(); 106 | const excludedHeadingText = textValue.trim() 107 | .replace(/^,/, '') 108 | .replace(/,$/, '') 109 | .split(',') 110 | .map((value: string) => value.trim()) 111 | .filter((value: string) => value.length > 0); 112 | 113 | this.plugin.settings.excludedHeadingText = excludedHeadingText; 114 | await this.plugin.saveSettings(); 115 | }); 116 | 117 | component.inputEl.placeholder = 'Table of Contents,Introduction,Side Note'; 118 | component.inputEl.classList.add('insta-toc-text-area'); 119 | }).infoEl.classList.add('insta-toc-text-info'); 120 | 121 | // Global excluded heading levels 122 | new Setting(containerEl) 123 | .setName('Excluded heading levels') 124 | .setDesc('Comma-separated list of heading levels to exclude globally within the Table of Contents.') 125 | .setTooltip('Valid values are 1-6.') 126 | .addTextArea((component: TextAreaComponent) => { 127 | component 128 | .setValue(this.plugin.settings.excludedHeadingLevels.join(',')) 129 | .onChange(async (value: string) => { 130 | const textValue = component.getValue(); 131 | const excludedHeadingLevels: HeadingLevel[] = textValue 132 | .replace(/^,/, '').replace(/,$/, '') 133 | .split(',') 134 | .map((value: string) => parseInt(value.trim()) as HeadingLevel) 135 | .filter((value: HeadingLevel) => value > 0 && value < 7); 136 | 137 | this.plugin.settings.excludedHeadingLevels = [...excludedHeadingLevels]; 138 | await this.plugin.saveSettings(); 139 | }); 140 | 141 | component.inputEl.classList.add('insta-toc-text-area'); 142 | component.inputEl.placeholder = '1,2,3,4,5,6'; 143 | }).infoEl.classList.add('insta-toc-text-info'); 144 | 145 | // Global excluded characters 146 | new Setting(containerEl) 147 | .setName('Excluded characters') 148 | .setDesc('Globally excluded heading characters.') 149 | .addTextArea((component: TextAreaComponent) => { 150 | component 151 | .setValue([...new Set(this.plugin.settings.excludedChars)].join(',')) 152 | .onChange(async (value: string) => { 153 | const textValue = component.getValue(); 154 | const excludedChars = new Set([ 155 | ...textValue.trim() 156 | .replace(/^,/, '').replace(/,$/, '') 157 | .split(',') 158 | .map((value: string) => value.trim()) 159 | .filter((value: string) => value.length > 0) 160 | ]); 161 | 162 | this.plugin.settings.excludedChars = [...excludedChars]; 163 | await this.plugin.saveSettings(); 164 | }); 165 | 166 | component.inputEl.classList.add('exclude-chars'); 167 | component.inputEl.placeholder = DefaultExcludedChars.join(','); 168 | }).infoEl.classList.add('insta-toc-text-info'); 169 | } 170 | } -------------------------------------------------------------------------------- /test/headings.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeEach, afterEach } from 'vitest'; 2 | import { 3 | testStandardHeadings, 4 | testHandleLinks, 5 | testHeadingsWithoutFirstLevel, 6 | testHeadingsMixed, 7 | testHeadingsWithSpecialChars, 8 | testOmitHeadingRegex, 9 | TestNames, 10 | TestName, 11 | Context, 12 | initialStandardHeadings, 13 | initialHeadingsWithSpecialChars, 14 | initialHeadingsWithoutFirstLevel, 15 | initialHeadingsMixed 16 | } from "./testingObjects"; 17 | 18 | 19 | function testGetIndentationLevel(headingLevel: number, headingLevelStack: number[]) { 20 | // Pop from the stack until we find a heading level less than the current 21 | while ( 22 | headingLevelStack.length > 0 && // Avoid indentation for the first heading 23 | headingLevel <= headingLevelStack[headingLevelStack.length - 1] 24 | ) { 25 | headingLevelStack.pop(); 26 | } 27 | headingLevelStack.push(headingLevel); 28 | 29 | const currentIndentLevel = headingLevelStack.length - 1; 30 | 31 | return { currentIndentLevel, headingLevelStack }; 32 | } 33 | 34 | describe('Headings', () => { 35 | let iteration = 0; 36 | let testId: TestName; 37 | const contextObject: Context = { 38 | "testStandardHeadings": { 39 | "initialHeadings": initialStandardHeadings, 40 | "formattedHeadings": [], 41 | "finalResult": "" 42 | }, 43 | "testHeadingsWithoutFirstLevel": { 44 | "initialHeadings": initialHeadingsWithoutFirstLevel, 45 | "formattedHeadings": [], 46 | "finalResult": "" 47 | }, 48 | "testHeadingsMixed": { 49 | "initialHeadings": initialHeadingsMixed, 50 | "formattedHeadings": [], 51 | "finalResult": "" 52 | }, 53 | "testHeadingsWithSpecialChars": { 54 | "initialHeadings": initialHeadingsWithSpecialChars, 55 | "formattedHeadings": [], 56 | "finalResult": "" 57 | } 58 | } 59 | 60 | beforeEach(() => { 61 | testId = TestNames[iteration] as TestName; 62 | console.log(`${'#'.repeat(10)}\n${testId}\n${'#'.repeat(10)}\n${contextObject[testId].initialHeadings.join('\n')}`); 63 | }); 64 | 65 | afterEach(() => { 66 | const finalResultArray = contextObject[testId].finalResult.split('\n') 67 | .reduce<[string, string | undefined][]>((acc, item, index) => { 68 | acc.push([contextObject[testId].initialHeadings[index], item]); 69 | return acc; 70 | }, []) 71 | .flatMap(([val1, val2]) => `\n${val1}\n⬇️ ⬇️ ⬇️\n${val2}\n`); // Explicitly define the initial value type as an array of tuples 72 | 73 | console.log(`\nFINAL RESULT:\n${finalResultArray}\n\n${'='.repeat(100)}\n${'='.repeat(100)}\n${'='.repeat(100)}\n\n`); 74 | iteration += 1; 75 | }); 76 | 77 | test('Returns indented list with links', () => { 78 | const md = (() => { 79 | let testHeadingLevelStack: number[] = []; 80 | const testHeadings: string[] = []; 81 | 82 | testStandardHeadings.forEach((test) => { 83 | const { currentIndentLevel, headingLevelStack } = testGetIndentationLevel(test.level, testHeadingLevelStack); 84 | testHeadingLevelStack = headingLevelStack; 85 | 86 | const heading = testHandleLinks('testStandardHeadings', test.heading, currentIndentLevel); 87 | testHeadings.push(heading); 88 | 89 | contextObject[testId].formattedHeadings.push(heading); 90 | }); 91 | 92 | return testHeadings.join('\n'); 93 | })(); 94 | 95 | const expectedMd = ` 96 | - [[testStandardHeadings#Title 1 Level 1|Title 1 Level 1]] 97 | - [[testStandardHeadings#Title 1 Level 2|Title 1 Level 2]] 98 | - [[testStandardHeadings#Title 1 Level 3|Title 1 Level 3]] 99 | - [[testStandardHeadings#Title 1 Level 4|Title 1 Level 4]] 100 | - [[testStandardHeadings#Title 1 Level 5|Title 1 Level 5]] 101 | - [[testStandardHeadings#Title 1 Level 6|Title 1 Level 6]] 102 | `.trim(); 103 | 104 | contextObject[testId].finalResult = md; 105 | expect(md).toEqual(expectedMd); 106 | }); 107 | 108 | test('Returns indented list with links if no first level', () => { 109 | const md = (() => { 110 | let testHeadingLevelStack: number[] = []; 111 | const testHeadings: string[] = []; 112 | 113 | testHeadingsWithoutFirstLevel.forEach((test) => { 114 | const { currentIndentLevel, headingLevelStack } = testGetIndentationLevel(test.level, testHeadingLevelStack); 115 | testHeadingLevelStack = headingLevelStack; 116 | 117 | const heading = testHandleLinks('testHeadingsWithoutFirstLevel', test.heading, currentIndentLevel); 118 | testHeadings.push(heading); 119 | 120 | contextObject[testId].formattedHeadings.push(heading); 121 | }); 122 | 123 | return testHeadings.join('\n'); 124 | })(); 125 | 126 | const expectedMd = ` 127 | - [[testHeadingsWithoutFirstLevel#Title 1 Level 2|Title 1 Level 2]] 128 | - [[testHeadingsWithoutFirstLevel#Title 1 Level 3|Title 1 Level 3]] 129 | - [[testHeadingsWithoutFirstLevel#Title 1 Level 4|Title 1 Level 4]] 130 | - [[testHeadingsWithoutFirstLevel#Title 1 Level 5|Title 1 Level 5]] 131 | - [[testHeadingsWithoutFirstLevel#Title 1 Level 6|Title 1 Level 6]] 132 | `.trim(); 133 | contextObject[testId].finalResult = md; 134 | expect(md).toEqual(expectedMd); 135 | }); 136 | 137 | test('Returns indented list from disorderly headings', () => { 138 | const md = (() => { 139 | let testHeadingLevelStack: number[] = []; 140 | const testHeadings: string[] = []; 141 | 142 | testHeadingsMixed.forEach((test) => { 143 | const { currentIndentLevel, headingLevelStack } = testGetIndentationLevel(test.level, testHeadingLevelStack); 144 | testHeadingLevelStack = headingLevelStack; 145 | 146 | const heading = testHandleLinks('testHeadingsMixed', test.heading, currentIndentLevel); 147 | testHeadings.push(heading); 148 | 149 | contextObject[testId].formattedHeadings.push(heading); 150 | }); 151 | 152 | return testHeadings.join('\n'); 153 | })(); 154 | 155 | const expectedMd = ` 156 | - [[testHeadingsMixed#Title 1 Level 4|Title 1 Level 4]] 157 | - [[testHeadingsMixed#Title 1 Level 1|Title 1 Level 1]] 158 | - [[testHeadingsMixed#Title 1 Level 6|Title 1 Level 6]] 159 | - [[testHeadingsMixed#Title 1 Level 2|Title 1 Level 2]] 160 | - [[testHeadingsMixed#Title 2 Level 2|Title 2 Level 2]] 161 | - [[testHeadingsMixed#Title 1 Level 3|Title 1 Level 3]] 162 | `.trim(); 163 | 164 | contextObject[testId].finalResult = md; 165 | expect(md).toEqual(expectedMd); 166 | }); 167 | 168 | 169 | test('Returns indented list with sanitized links from special chars and HTML', () => { 170 | const md = (() => { 171 | let testHeadingLevelStack: number[] = []; 172 | const testHeadings: string[] = []; 173 | 174 | testHeadingsWithSpecialChars.forEach((test) => { 175 | if (!test.heading.match(testOmitHeadingRegex)) { 176 | const { currentIndentLevel, headingLevelStack } = testGetIndentationLevel(test.level, testHeadingLevelStack); 177 | testHeadingLevelStack = headingLevelStack; 178 | 179 | const heading = testHandleLinks('testHeadingsWithSpecialChars', test.heading, currentIndentLevel); 180 | testHeadings.push(heading); 181 | 182 | contextObject[testId].formattedHeadings.push(heading); 183 | } 184 | }); 185 | 186 | return testHeadings.join('\n'); 187 | })(); 188 | 189 | const expectedMd = ` 190 | - [[testHeadingsWithSpecialChars#Title 1 \`level 1\` {with special chars}, **bold**, _italic_, a-tag, ==highlighted== and ~~strikethrough~~ text|Title 1 level 1 with special chars, bold, italic, a-tag, highlighted and strikethrough text]] 191 | - [[testHeadingsWithSpecialChars#Title 1 level 2 with HTML|Title 1 level 2 with HTML]] 192 | - [[testHeadingsWithSpecialChars#Title 1 level 3 wikilink1 wikilink2 wikitext2 [mdlink](https://mdurl)|Title 1 level 3 wikilink1 wikitext2 mdlink]] 193 | - [[testHeadingsWithSpecialChars#Title 1 level 4 wikilink1 wikilink2 wikitext2 [mdlink1](https://mdurl) wikilink3 wikilink4 wikitext3 [mdlink2](https://mdurl)|Title 1 level 4 wikilink1 wikitext2 mdlink1 wikilink3 wikitext3 mdlink2]] 194 | `.trim(); 195 | 196 | contextObject[testId].finalResult = md; 197 | expect(md).toEqual(expectedMd); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Insta TOC Plugin 2 | 3 | [![Version](https://img.shields.io/github/v/release/iLiftALot/insta-toc?include_prereleases&label=latest&logo=github&labelColor=blue)](https://github.com/iLiftALot/insta-toc/releases) [![Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22insta-toc%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json)](https://obsidian.md/plugins?search=insta%20toc) [![NPM Version](https://img.shields.io/npm/v/insta-toc)](https://www.npmjs.com/package/insta-toc) 4 | 5 | > A plugin to ***dyamically*** generate and maintain a table of contents for you in ***real time***. 6 | 7 | ## Table of Contents 8 | - [Demonstration](#demonstration) 9 | - [Features: Insta TOC vs Other ToC Plugins](#features-insta-toc-vs-other-toc-plugins) 10 | - [Usage](#usage) 11 | - [Installation](#installation) 12 | - [Obsidian](#obsidian) 13 | - [BRAT](#brat) 14 | - [npm](#npm) 15 | - [Manual](#manual) 16 | - [Contributing](#contributing) 17 | - [Road Map](#road-map) 18 | 19 | 20 | ## Demonstration 21 | ![./assets/media/assets/media/demonstration.gif](https://raw.githubusercontent.com/iLiftALot/insta-toc/master/assets/media/demonstration.gif) 22 | 23 | 24 | ## Features: Insta TOC vs Other ToC Plugins 25 | There are various other ToC plugins for Obsidian, however, they come with certain limitations which this plugin aims to mitigate and improve upon which includes: 26 | 27 | **Seamless Integration & Dynamic Generation** 28 | - Just insert the code block and start typing. There's nothing more to it. 29 | - Other ToC plugins generate the ToC via command activation. 30 | - This plugin is designed for performance and simplicity for maximum convenience and organization. 31 | 32 | **Omit Specific Headings** 33 | - Exclude any heading you want from the ToC by simply adding `` to the end of the heading. 34 | - Alternatively, utilize the local settings to omit specific headings. 35 | 36 | **HTML & Special Symbols** 37 | - Feel free to include HTML or any kind of special symbols within headings. This plugin will handle these cases elegantly. 38 | - You can additionally specify which characters should be escaped within the local settings. 39 | 40 | **Heading Hierarchy Handling** 41 | - Include any type of heading hierarchy you want. Your heading structure doesn't have to be any certain way. 42 | - Other plugins will prohibit the ToC insertion if the heading hierachy is not in a particular optimal format. 43 | 44 | **Markdown Links & Wiki-Links** 45 | - This plugin will handle multiple of both markdown links (`[Title]\(https://link)`) and wiki-links (`[[file-name.md]]`) within headings. 46 | 47 | **Settings**
48 |
    49 |
  • Bullet Style - Select your preferred list-bullet style within the settings tab.
  • 50 | 51 |
  • Update Delay - Configure the delay between ToC updates.
  • 52 | 53 |
  • Exclusions - You will have multiple custimization choices pertaining to exlcuding specific heading text, individual characters, and heading levels.
  • 54 | 55 |
  • Indentation Width - Determine your preferred amount of indentation spacing.
  • 56 | 57 |
  • Local File Settings
  • 58 |
59 | 60 | 61 | ## Usage 62 | **General Usage** 63 | - Insert the `insta-toc` code block: 64 | 65 | ~~~markdown 66 | ```insta-toc 67 | ``` 68 | ~~~ 69 | 70 | **Omit Specific Headings** 71 | - If you want to omit a specific heading from the ToC, simply add `` to the end of the heading. 72 | 73 | ``` 74 | # Heading 1 75 | ``` 76 | 77 | - Alternatively, utilize the local settings: 78 | ```yml 79 | --- 80 | omit: [ 81 | "Heading 1", 82 | "Heading 2" 83 | ] 84 | --- 85 | ``` 86 | 87 |
↕️    ↕️    ↕️    ↕️
88 | 89 | ```yml 90 | --- 91 | omit: 92 | - Heading 1 93 | - Heading 2 94 | --- 95 | ``` 96 | --- 97 | 98 | **Local ToC Settings Guide** 99 | >
⚠️ FORMAT CAUTION ⚠️
100 | > 101 | > The local settings use YAML formatting, which is a format that is very particular about perfect spacing. 102 | > I'll be implementing auto-correction logic soon to account for this, but for the time being ensure that you are only indenting with 2 spaces, otherwise you will get errors. 103 | 104 | - Type Guide: 105 | ```yml 106 | --- 107 | title: 108 | name: [string: any] 109 | - The title of the ToC. 110 | level: [number: 1 | 2 | 3 | 4 | 5 | 6] 111 | - The heading level of the title. 112 | center: [boolean: true | false] 113 | - Optionally center position of the title. 114 | exclude: [string: any | RegExp: /.../] 115 | - Exclude specific headings based on a string of characters (e.g., ",._-+=") or a regular expression (e.g., /[^a-zA-Z0-9]/). 116 | - NOTE: Currently, this will include global excluded characters as well. 117 | style: 118 | listType: [string: "number" | "dash"] 119 | - The type of list-bullet style. 120 | omit: [string[]: any[]] 121 | - Omit specific headings from the ToC. 122 | levels: 123 | min: [number: 1 | 2 | 3 | 4 | 5 | 6] 124 | - The minimum heading level to include in the ToC. 125 | max: [number: 1 | 2 | 3 | 4 | 5 | 6] 126 | - The maximum heading level to include in the ToC. 127 | --- 128 | ``` 129 | 130 | - Example 1: 131 | ```yml 132 | --- 133 | title: 134 | name: "Table of Contents" 135 | level: 2 136 | center: false 137 | exclude: ",._-+" 138 | style: 139 | listType: "dash" 140 | omit: [ 141 | "Heading 1", 142 | "Heading 2" 143 | ] 144 | levels: 145 | min: 1 146 | max: 3 147 | --- 148 | ``` 149 | 150 | - Example 2: 151 | ```yml 152 | --- 153 | title: 154 | name: "Table of Contents" 155 | level: 1 156 | center: true 157 | exclude: /[^a-zA-Z0-9]/ 158 | style: 159 | listType: number 160 | omit: 161 | - Heading 3 162 | - Heading 4 163 | levels: 164 | min: 2 165 | max: 6 166 | --- 167 | ``` 168 | 169 | 170 | ## Installation 171 | 172 | ### Obsidian 173 | 1. Open Obsidian and press `CMD+,`. 174 | 2. Navigate to the Community plugins tab and click the `Browse` button. 175 | 3. Navigate to the search bar and type `Insta TOC` 176 | 4. Click the install button. 177 | 178 | ### BRAT 179 | 1. Install [BRAT](https://github.com/TfTHacker/obsidian42-brat) community plugin. 180 | 2. Open Obsidian and press `CMD+SHIFT+P`. 181 | 3. Type `>BRAT: Plugins: Add a beta plugin for testing` and select the option. 182 | 4. Insert `https://github.com/iLiftALot/insta-toc` and submit. 183 | 184 | ### npm 185 | ```shell 186 | npm install insta-toc 187 | ``` 188 | 189 | ### Manual 190 | 1. Download the [latest release](https://github.com/iLiftALot/insta-toc/releases). 191 | 2. Extract the `insta-toc` folder from the zip to your vault's plugins folder: `/path/to//.obsidian/plugins/`. 192 | *Note*: On some machines the .obsidian folder may be hidden. On MacOS you should be able to press `CMD+SHIFT+.` to show the folder in Finder. 193 | 3. Reload Obsidian. 194 | 195 | ## Contributing 196 | - [Report a Bug](https://github.com/iLiftALot/insta-toc/issues/new?assignees=iLiftALot&labels=bug&template=&title=Bug%3A+) 197 | - [Suggest a Feature](https://github.com/iLiftALot/insta-toc/issues/new?assignees=iLiftALot&labels=feature-request&template=&title=FR%3A+) 198 | - [Suggest Documentation](https://github.com/iLiftALot/insta-toc/issues/new?assignees=iLiftALot&labels=documentation&template=&title=Doc%3A+) 199 | - [Submit a Pull Request](https://github.com/iLiftALot/insta-toc/pulls) 200 | 201 | 202 | --- 203 | 204 | 205 | ## Road Map 206 |
    207 |
  • Handle various heading formats
  • 208 |
      209 |
    • Markdown Links
    • 210 |
    • Wiki-Links
    • 211 |
    • HTML
    • 212 |
    • Tags
    • 213 |
    • Special Characters
    • 214 |
    • ...
    • 215 |
    216 |
  • Configure Settings Tab
  • 217 |
      218 |
    • Indentation
    • 219 |
    • Bullet types
    • 220 |
        221 |
      • Number
      • 222 |
      • Dash
      • 223 |
      • ...
      • 224 |
      225 |
    • ToC Update Delay
    • 226 |
    • Special Character Specificatio
    • 227 |
    • Preferences for customized TOC appearance
    • 228 |
    • ...
    • 229 |
    230 |
  • Improve ToC Interaction/Interface
  • 231 |
      232 |
    • Add folding capabilities
    • 233 |
    • Add ability to exclude specific headings
    • 234 |
    • Add local setting capabilities
    • 235 |
    • ...
    • 236 |
    237 |
  • Configure specific formatting for various exporting circumstances
  • 238 |
      239 |
    • PDF
    • 240 |
    • HTML
    • 241 |
    • Markdown
    • 242 |
    • ...
    • 243 |
    244 |
245 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HeadingCache, 3 | CachedMetadata, 4 | SectionCache, 5 | Editor, 6 | EditorPosition, 7 | parseYaml, 8 | EditorRange 9 | } from "obsidian"; 10 | import { getDefaultLocalSettings, instaTocCodeBlockId, localTocSettingsRegex } from "./constants"; 11 | import { HeadingLevel, LocalTocSettings, ValidatedInstaToc, ValidCacheType } from "./types"; 12 | import { deepMerge, escapeRegExp, isHeadingLevel, isRegexPattern } from "./Utils"; 13 | import InstaTocPlugin from "./main"; 14 | 15 | export class Validator { 16 | private plugin: InstaTocPlugin; 17 | private previousHeadings: HeadingCache[] = []; 18 | 19 | public editor: Editor; 20 | public cursorPos: EditorPosition; 21 | public tocInsertPos: EditorRange; 22 | 23 | public fileHeadings: HeadingCache[]; 24 | public localTocSettings: LocalTocSettings; 25 | public updatedLocalSettings: LocalTocSettings | undefined; 26 | public metadata: CachedMetadata; 27 | public instaTocSection: SectionCache; 28 | 29 | constructor( 30 | plugin: InstaTocPlugin, 31 | metadata: CachedMetadata, 32 | editor: Editor, 33 | cursorPos: EditorPosition 34 | ) { 35 | this.plugin = plugin; 36 | this.metadata = metadata; 37 | this.editor = editor; 38 | this.cursorPos = cursorPos; 39 | this.localTocSettings = getDefaultLocalSettings(); 40 | } 41 | 42 | // Method to update the validator properties while maintaining the previous state 43 | public update( 44 | plugin: InstaTocPlugin, 45 | metadata: CachedMetadata, 46 | editor: Editor, 47 | cursorPos: EditorPosition 48 | ): void { 49 | this.plugin = plugin; 50 | this.metadata = metadata; 51 | this.editor = editor; 52 | this.cursorPos = cursorPos; 53 | } 54 | 55 | // Method to compare current headings with previous headings 56 | private haveHeadingsChanged(): boolean { 57 | const currentHeadings: HeadingCache[] = this.metadata.headings || []; 58 | const noPrevHeadings: boolean = this.previousHeadings.length === 0; 59 | const diffHeadingsLength: boolean = currentHeadings.length !== this.previousHeadings.length; 60 | 61 | const noHeadingsChange: boolean = noPrevHeadings || diffHeadingsLength 62 | ? false 63 | : currentHeadings.every( 64 | (headingCache: HeadingCache, index: number) => { 65 | return ( 66 | headingCache.heading === this.previousHeadings[index].heading && 67 | headingCache.level === this.previousHeadings[index].level 68 | ); 69 | } 70 | ); 71 | 72 | if (noHeadingsChange) return false; 73 | 74 | // Headings have changed, update previousHeadings 75 | this.previousHeadings = currentHeadings; 76 | 77 | return true; 78 | } 79 | 80 | // Type predicate to assert that metadata has headings and sections 81 | private hasHeadingsAndSections(): this is Validator & { 82 | metadata: ValidCacheType 83 | } { 84 | return ( 85 | !!this.metadata && 86 | !!this.metadata.headings && 87 | !!this.metadata.sections 88 | ) 89 | } 90 | 91 | // Finds and stores the instaTocSection 92 | private hasInstaTocSection(): this is Validator & { 93 | metadata: ValidCacheType; 94 | instaTocSection: SectionCache 95 | } { 96 | if (!this.hasHeadingsAndSections()) return false; 97 | 98 | const instaTocSection: SectionCache | undefined = this.metadata.sections.find( 99 | (section: SectionCache) => 100 | section.type === 'code' && 101 | this.editor.getLine(section.position.start.line) === `\`\`\`${instaTocCodeBlockId}` 102 | ); 103 | 104 | if (instaTocSection) { 105 | this.instaTocSection = instaTocSection; 106 | return true; 107 | } 108 | 109 | return false; 110 | } 111 | 112 | // Provides the insert location range for the new insta-toc codeblock 113 | private setTocInsertPos(): void { 114 | // Extract the star/end line/character index 115 | const startLine: number = this.instaTocSection.position.start.line; 116 | const startCh = 0; 117 | const endLine: number = this.instaTocSection.position.end.line; 118 | const endCh: number = this.instaTocSection.position.end.col; 119 | 120 | const tocStartPos: EditorPosition = { line: startLine, ch: startCh }; 121 | const tocEndPos: EditorPosition = { line: endLine, ch: endCh }; 122 | 123 | this.tocInsertPos = { from: tocStartPos, to: tocEndPos } 124 | } 125 | 126 | private configureLocalSettings(): void { 127 | const tocRange = this.editor.getRange( 128 | this.tocInsertPos.from, 129 | this.tocInsertPos.to 130 | ); 131 | const tocData = tocRange.match(localTocSettingsRegex); 132 | 133 | if (!tocData) return; 134 | 135 | const [, settingString] = tocData; 136 | 137 | this.validateLocalSettings(settingString); 138 | } 139 | 140 | private validateLocalSettings(yml: string): void { 141 | let parsedYml: Partial; 142 | 143 | try { 144 | parsedYml = parseYaml(yml); 145 | } catch (err) { 146 | this.localTocSettings = this.updatedLocalSettings || this.localTocSettings; 147 | const errMsg = 'Invalid YAML in insta-toc settings:\n' + err; 148 | 149 | console.error(errMsg); 150 | new Notice(errMsg); 151 | 152 | return; 153 | } 154 | 155 | const validationErrors: string[] = []; 156 | 157 | // Validate and assign 'title' 158 | if (parsedYml.title !== undefined) { 159 | const title = parsedYml.title; 160 | 161 | if (typeof title !== 'object' || title === null) { 162 | validationErrors.push("'title' must be an object."); 163 | } else { 164 | const { name, level, center } = title; 165 | 166 | if (name !== undefined && typeof name !== 'string') { 167 | validationErrors.push("'title.name' must be a string indicating the title to be displayed on the ToC."); 168 | } 169 | 170 | if (level !== undefined && !isHeadingLevel(level)) { 171 | validationErrors.push("'title.level' must be an integer between 1 and 6 indicating the heading level of the ToC title."); 172 | } 173 | 174 | if (center !== undefined && !(typeof center === 'boolean')) { 175 | validationErrors.push("'title.center' must be a boolean indicating whether the title position should be centered."); 176 | } 177 | } 178 | } 179 | 180 | // Validate and assign 'exclude' 181 | if (parsedYml.exclude !== undefined) { 182 | if (typeof parsedYml.exclude !== 'string') { 183 | validationErrors.push("'exclude' must be a string (\"...\") containing each character to exclude, or a regex pattern (/.../)."); 184 | } 185 | } 186 | 187 | // Validate and assign 'style' 188 | if (parsedYml.style !== undefined) { 189 | const style = parsedYml.style; 190 | 191 | if (typeof style !== 'object' || style === null) { 192 | validationErrors.push("'style' must be an object."); 193 | } else { 194 | const { listType } = style; 195 | 196 | if (listType !== undefined && !['dash', 'number'].includes(listType)) { 197 | validationErrors.push("'style.listType' must be 'dash' or 'number'."); 198 | } 199 | } 200 | } 201 | 202 | // Validate and assign 'omit' 203 | if (parsedYml.omit !== undefined) { 204 | if (!Array.isArray(parsedYml.omit)) { 205 | validationErrors.push("'omit' must be an array of strings indicating the text of each heading you'd like to omit."); 206 | } else { 207 | for (const item of parsedYml.omit) { 208 | if (typeof item !== 'string') { 209 | validationErrors.push("'omit' array must contain only strings indicating the text of headings you'd like to omit."); 210 | break; 211 | } 212 | } 213 | } 214 | } 215 | 216 | // Validate and assign 'levels' 217 | if (parsedYml.levels !== undefined) { 218 | const levels = parsedYml.levels; 219 | 220 | if (typeof levels !== 'object' || levels === null) { 221 | validationErrors.push("'levels' must be an object."); 222 | } else { 223 | const { min, max } = levels; 224 | 225 | if (min !== undefined && !isHeadingLevel(min)) { 226 | validationErrors.push("'levels.min' must be an integer between 1 and 6 indicating the minimum heading level to include."); 227 | } 228 | 229 | if (max !== undefined && !isHeadingLevel(max)) { 230 | validationErrors.push("'levels.max' must be an integer between 1 and 6 indicating the maximum heading level to include."); 231 | } 232 | 233 | if (min !== undefined && max !== undefined && min > max) { 234 | validationErrors.push("'levels.min' cannot be greater than 'levels.max'."); 235 | } 236 | } 237 | } 238 | 239 | if (validationErrors.length > 0) { 240 | const validationErrorMsg: string = 'Invalid properties in insta-toc settings:\n' + validationErrors.join('\n'); 241 | 242 | console.error(validationErrorMsg); 243 | new Notice(validationErrorMsg); 244 | 245 | this.updatedLocalSettings = this.localTocSettings; 246 | } else { 247 | // All validations passed; merge 248 | if (!this.updatedLocalSettings) { 249 | this.updatedLocalSettings = deepMerge( 250 | this.localTocSettings, 251 | parsedYml, 252 | true 253 | ); 254 | } else { 255 | this.updatedLocalSettings = deepMerge( 256 | this.updatedLocalSettings, 257 | parsedYml, 258 | false 259 | ); 260 | } 261 | } 262 | 263 | this.localTocSettings = this.updatedLocalSettings; 264 | } 265 | 266 | private cursorInToc(): boolean { 267 | return this.cursorPos.line >= this.instaTocSection.position.start.line && 268 | this.cursorPos.line <= this.instaTocSection.position.end.line; 269 | } 270 | 271 | private setFileHeadings(): void { 272 | if (this.metadata.headings) { 273 | // Store the file headings to reference in later code 274 | this.fileHeadings = this.metadata.headings 275 | .filter((heading: HeadingCache) => { 276 | const headingText: string = heading.heading.trim(); 277 | const headingLevel = heading.level as HeadingLevel; 278 | 279 | return ( 280 | // Omit headings with "" 281 | !headingText.match(//) && 282 | // Omit headings included within local "omit" setting 283 | !this.localTocSettings.omit.includes(headingText) && 284 | // Omit headings with levels outside of the specified local min/max setting 285 | headingLevel >= this.localTocSettings.levels.min && 286 | headingLevel <= this.localTocSettings.levels.max && 287 | // Omit empty headings 288 | headingText.trim().length > 0 && 289 | // Omit heading text specified in the global exclude setting 290 | !this.plugin.settings.excludedHeadingText.includes(headingText) && 291 | // Omit heading levels specified in the global exclude setting 292 | !this.plugin.settings.excludedHeadingLevels.includes(headingLevel) 293 | ); 294 | }) 295 | .map((heading: HeadingCache) => { 296 | let modifiedHeading = heading.heading; 297 | const patterns: string[] = []; 298 | 299 | // Process global excluded characters 300 | if ( 301 | this.plugin.settings.excludedChars && 302 | this.plugin.settings.excludedChars.length > 0 303 | ) { 304 | // Escape and join global excluded characters 305 | const escapedGlobalChars = this.plugin.settings.excludedChars.map( 306 | char => escapeRegExp(char) 307 | ).join(''); 308 | 309 | if (escapedGlobalChars.length > 0) { 310 | patterns.push(`[${escapedGlobalChars}]`); 311 | } 312 | } 313 | 314 | // Process local 'exclude' setting 315 | if ( 316 | this.localTocSettings.exclude && 317 | this.localTocSettings.exclude.length > 0 318 | ) { 319 | const excludeStr = this.localTocSettings.exclude; 320 | 321 | if (isRegexPattern(excludeStr)) { 322 | // It's a regex pattern (e.g., '/\d+/'), remove the slashes 323 | const regexBody = excludeStr.slice(1, -1); 324 | 325 | patterns.push(`(${regexBody})`); 326 | } else { 327 | // It's a string of characters to exclude 328 | const escapedLocalChars = escapeRegExp(excludeStr); 329 | 330 | if (escapedLocalChars.length > 0) { 331 | patterns.push(`[${escapedLocalChars}]`); 332 | } 333 | } 334 | } 335 | 336 | // Build and apply the combined regex pattern 337 | if (patterns.length > 0) { 338 | const combinedPattern = new RegExp(patterns.join('|'), 'g'); 339 | modifiedHeading = modifiedHeading.replace(combinedPattern, ''); 340 | } 341 | 342 | return { ...heading, heading: modifiedHeading }; 343 | } 344 | ); 345 | } 346 | } 347 | 348 | // Validates all conditions and asserts the type when true 349 | public isValid(): this is Validator & ValidatedInstaToc { 350 | const hasInstaTocSectionResult: boolean = this.hasInstaTocSection(); 351 | 352 | // If file has no insta-toc section, skip processing 353 | if (!hasInstaTocSectionResult) { 354 | // Set the plugin.hasTocBlock variable, considering the 355 | // code block processor in main.ts can't 356 | this.plugin.hasTocBlock = false; 357 | 358 | return false; 359 | } 360 | 361 | const headingsChanged: boolean = this.haveHeadingsChanged(); 362 | 363 | // If the headings have not changed, skip processing 364 | if (!headingsChanged) return false; 365 | 366 | // Process and store data for later use 367 | this.setTocInsertPos(); 368 | this.configureLocalSettings(); 369 | this.setFileHeadings(); 370 | 371 | // Lastly, ensure the cursor is not within the ToC 372 | return !this.cursorInToc(); 373 | } 374 | } 375 | --------------------------------------------------------------------------------