├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .versionrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── manifest.json ├── media ├── .gitkeep ├── inconsistent-heading-depth.jpg ├── inline-headings.jpg ├── screenshot.jpg ├── settings.jpg ├── toc-command-options.jpg ├── toc-command.jpg ├── varied-style-bullet.jpg └── varied-style-number.jpg ├── package.json ├── scripts ├── manifest-updater.js └── versions-updater.js ├── src ├── constants.ts ├── insert-command.modal.ts ├── main.ts ├── models │ ├── __tests__ │ │ └── heading.test.ts │ └── heading.ts ├── obsidian-ex.d.ts ├── renderers │ ├── code-block-renderer.ts │ └── dynamic-injection-renderer.ts ├── settings-tab.ts ├── styles.css ├── types.ts └── utils │ ├── __tests__ │ ├── __snapshots__ │ │ └── extract-headings.test.ts.snap │ └── extract-headings.test.ts │ ├── config.ts │ └── extract-headings.ts ├── tsconfig.json ├── types.d.ts └── versions.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: aidurber 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: obsidian-dynamic-toc # Change this to the name of your plugin-id folder 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" # You might need to adjust this value to your own version 21 | - name: Build 22 | id: build 23 | run: | 24 | npm install 25 | npm run build --if-present 26 | mkdir ${{ env.PLUGIN_NAME }} 27 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 28 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 29 | ls 30 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 31 | - name: Create Release 32 | id: create_release 33 | uses: actions/create-release@v1 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | VERSION: ${{ github.ref }} 37 | with: 38 | tag_name: ${{ github.ref }} 39 | release_name: ${{ github.ref }} 40 | draft: false 41 | prerelease: false 42 | - name: Upload zip file 43 | id: upload-zip 44 | uses: actions/upload-release-asset@v1 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | with: 48 | upload_url: ${{ steps.create_release.outputs.upload_url }} 49 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 50 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 51 | asset_content_type: application/zip 52 | - name: Upload main.js 53 | id: upload-main 54 | uses: actions/upload-release-asset@v1 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | with: 58 | upload_url: ${{ steps.create_release.outputs.upload_url }} 59 | asset_path: ./main.js 60 | asset_name: main.js 61 | asset_content_type: text/javascript 62 | - name: Upload styles.css 63 | id: upload-styles 64 | uses: actions/upload-release-asset@v1 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | upload_url: ${{ steps.create_release.outputs.upload_url }} 69 | asset_path: ./styles.css 70 | asset_name: styles.css 71 | asset_content_type: text/css 72 | - name: Upload manifest.json 73 | id: upload-manifest 74 | uses: actions/upload-release-asset@v1 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | upload_url: ${{ steps.create_release.outputs.upload_url }} 79 | asset_path: ./manifest.json 80 | asset_name: manifest.json 81 | asset_content_type: application/json 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | *-error.log 4 | package-lock.json 5 | yarn.lock 6 | .npmrc 7 | dist/ 8 | .DS_STORE 9 | main.js 10 | styles.css 11 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | const versionUpdater = { 2 | filename: "versions.json", 3 | updater: require("./scripts/versions-updater"), 4 | }; 5 | const manifestUpdater = { 6 | filename: "manifest.json", 7 | updater: require("./scripts/manifest-updater"), 8 | }; 9 | 10 | const packageJson = { 11 | filename: "package.json", 12 | type: "json", 13 | }; 14 | module.exports = { 15 | bumpFiles: [packageJson, versionUpdater, manifestUpdater], 16 | packageFiles: [packageJson], 17 | }; 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.0.27](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.26...0.0.27) (2022-03-05) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * codeblock not rerendering live preview ([ee06781](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/ee067813482eca2f6b962a913d4f7ff255c33560)) 11 | 12 | ### [0.0.26](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.25...0.0.26) (2022-02-02) 13 | 14 | 15 | ### Features 16 | 17 | * add varied style support ([f5afc17](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/f5afc1731e837568f6978b1cf9cf15b762cd95f5)) 18 | 19 | ### [0.0.25](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.24...0.0.25) (2022-02-02) 20 | 21 | 22 | ### Features 23 | 24 | * add inline breakcrumb style ([93dba71](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/93dba71dcfad10bde5924ebf45f6e11b183a3691)) 25 | 26 | ### [0.0.24](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.23...0.0.24) (2022-02-01) 27 | 28 | 29 | ### Features 30 | 31 | * support inconsistent heading depths ([3ffb6d7](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/3ffb6d70281b461527f9d0c6c30754efd65f7638)) 32 | 33 | ### [0.0.23](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.22...0.0.23) (2022-01-17) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * add live preview fix styles ([cdabcae](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/cdabcaee8ef77fad74ffb1f3e20352f28083fbb6)) 39 | 40 | ### [0.0.22](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.21...0.0.22) (2022-01-01) 41 | 42 | ### [0.0.21](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.20...0.0.21) (2022-01-01) 43 | 44 | 45 | ### Features 46 | 47 | * add title support ([6a72d46](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/6a72d46b07656d8bbbf559df14469b8fe96bd524)) 48 | 49 | ### [0.0.20](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.19...0.0.20) (2021-12-22) 50 | 51 | ### [0.0.19](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.18...0.0.19) (2021-12-21) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * correctly parse link headings with aliases ([a90275b](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/a90275b33a91c276fd34f0fc15d7eeb35b500568)) 57 | * hide new extractor behind feature flag ([e82eac4](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/e82eac4e23ff1898dbf555fa8524b64bde502d00)) 58 | * set new header extractor to false ([4add54b](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/4add54b1e8ada2835f246636f7ba1f20842a7ba5)) 59 | 60 | ### [0.0.18](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.17...0.0.18) (2021-11-30) 61 | 62 | 63 | ### Features 64 | 65 | * add insert command ([c2c0bdd](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/c2c0bdd48ca6286bb48e478f8c6cc04fc43be741)) 66 | 67 | ### [0.0.17](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.16...0.0.17) (2021-11-10) 68 | 69 | 70 | ### Features 71 | 72 | * add azure wiki provider ([d08797f](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/d08797f9594761aa2e2a173e541814f5f764dca5)) 73 | 74 | ### [0.0.16](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.15...0.0.16) (2021-10-12) 75 | 76 | 77 | ### Bug Fixes 78 | 79 | * pass in key not the value ([9879ce1](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/9879ce1426a60f6565b11da252a77276fdd6c6bc)) 80 | 81 | ### [0.0.15](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.14...0.0.15) (2021-10-12) 82 | 83 | 84 | ### Bug Fixes 85 | 86 | * renderer looking for incorrect elements ([25d1e90](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/25d1e905e822d2ef56da7e65c5a7b05f92646cdf)) 87 | 88 | ### [0.0.14](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.13...0.0.14) (2021-09-25) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * error when no matchers ([2723a6a](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/2723a6a9484d3b758bd7074536cf9ab9d3617b54)) 94 | 95 | ### [0.0.13](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.12...0.0.13) (2021-09-24) 96 | 97 | 98 | ### Features 99 | 100 | * **setting:** add support all renderer setting ([bdd8daa](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/bdd8daa89a33f3aaa19bd733a408f65a61790b03)) 101 | * support multiple matchers ([362a824](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/362a8246b6799c39f22ddeab0dac10199f9e2cf1)) 102 | 103 | ### [0.0.12](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.11...0.0.12) (2021-09-24) 104 | 105 | 106 | ### Features 107 | 108 | * add devonthink and thebrain support ([8b5532f](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/8b5532f9e9558f403bf6af1b1254b731df6579e0)) 109 | 110 | ### [0.0.11](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.10...0.0.11) (2021-09-09) 111 | 112 | ### [0.0.10](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.9...0.0.10) (2021-09-09) 113 | 114 | 115 | ### Bug Fixes 116 | 117 | * use registerEvent ([9dfb443](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/9dfb443b00f18254e350bf9a368d746ad13e15ec)) 118 | 119 | ### [0.0.9](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.8...0.0.9) (2021-09-09) 120 | 121 | 122 | ### Bug Fixes 123 | 124 | * improve stability of dynamic injection on file changes ([e90a525](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/e90a525c7d0b5358183b408b72c9540f3286304a)) 125 | 126 | ### [0.0.8](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.7...0.0.8) (2021-08-30) 127 | 128 | 129 | ### Bug Fixes 130 | 131 | * **headings:** handle wiki link headings ([1f96b15](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/1f96b15e75b6d51f1ca8f66ba229035aeb781d74)) 132 | 133 | ### [0.0.7](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.6...0.0.7) (2021-08-28) 134 | 135 | 136 | ### Features 137 | 138 | * **dynamic:** add dymanic injection ([ac7b5be](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/ac7b5be3c432ed1b5b69bded6aefcdcb94b8f3b5)) 139 | 140 | ### [0.0.6](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.5...0.0.6) (2021-08-28) 141 | 142 | 143 | ### Features 144 | 145 | * add min and max depth options ([98ab991](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/98ab9916052c625ba6fd9e0e2f1563173c8c7a19)) 146 | * add settings to override defaults globally ([79ba2fa](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/79ba2fa97c6432930a9125fde1ca3341150796ee)) 147 | * **settings:** add min and max header depth ([498eb90](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/498eb90e39aee244e18351c11bea443bffe60e5c)) 148 | 149 | ### [0.0.5](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.4...0.0.5) (2021-08-27) 150 | 151 | ### [0.0.4](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.3...0.0.4) (2021-08-27) 152 | 153 | ### [0.0.3](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/compare/0.0.2...0.0.3) (2021-08-27) 154 | 155 | ### 0.0.2 (2021-08-27) 156 | 157 | 158 | ### Features 159 | 160 | * initial proof of concept ([3c8562e](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/commit/3c8562e5acac9afcff5fcf8fabe84ed27f8290b9)) 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ Notice 2 | I'm unable to find the time to keep this repository well maintained, and up-to-date with Obsidian. Life gets in the way. This plugin should be forked/rebuilt by someone who is willing to find the time to build a plugin that the Obsidian community deserves. 3 | 4 | 5 | # Obsidian Dynamic ToC 6 | 7 | An Obsidian plugin to generate Tables of Contents that stay up to date with your document outline. Heavily inspired from [hipstersmoothie/obsidian-plugin-toc](https://github.com/hipstersmoothie/obsidian-plugin-toc) 8 | 9 | ![](media/screenshot.jpg) 10 | 11 | ## Foreword 12 | 13 | This plugin attempts to parse your document headings and generate a markdown table of contents for them. There have been a handful of issues raised which are due to how people are using headings. Headings offer a lexical structure to a document, they are not intended to be used for style. 14 | 15 | The following is an example of inconsistent heading depth. Instead of a level 4 heading it should be a level 3 heading. 16 | 17 | ```md 18 | ## Level 2 19 | 20 | #### Level 4 21 | ``` 22 | 23 | The following is an example of consistent heading depth. After a level 2 heading the next level is 3 24 | 25 | ```md 26 | ## Level 2 27 | 28 | ### Level 3 29 | ``` 30 | 31 | 👉 You can of course choose to structure your documents **as you wish**, but this plugin may not work effectively. I do attempt to make some exceptions but I will hide them behind settings to not interfere with peoples work flows, reduce stability, and to keep development time low. See [Inconsistent Heading Depth](#inconsistent-heading-depth) 32 | 33 | ## Usage 34 | 35 | ### Code Block 36 | 37 | It's really simple to use, just add a code block to your document: 38 | 39 | > 👉YAML does not support tabs, only use spaces ([source](http://yaml.org/faq.html)) 40 | 41 | **Defaults** 42 | 43 | ````markdown 44 | ```toc 45 | style: bullet | number | inline (default: bullet) 46 | min_depth: number (default: 2) 47 | max_depth: number (default: 6) 48 | title: string (default: undefined) 49 | allow_inconsistent_headings: boolean (default: false) 50 | delimiter: string (default: |) 51 | varied_style: boolean (default: false) 52 | ``` 53 | ```` 54 | 55 | **Example** 56 | 57 | ````markdown 58 | ```toc 59 | style: number 60 | min_depth: 1 61 | max_depth: 6 62 | ``` 63 | ```` 64 | 65 | You can specify the options on a case-by-case basis in your documents, or you can override the defaults in settings. If you have settings you always want to use, your usage just becomes: 66 | 67 | ````markdown 68 | ```toc 69 | 70 | ``` 71 | ```` 72 | 73 | ### Inline Style 74 | 75 | Inline styles render the highest level of heading such as H2 `## Heading 2`, you can couple this with the delimiter option to generate a breadcrumbs like effect for your headings. 76 | 77 | ![](media/inline-headings.jpg) 78 | 79 | See [Feature Request: Inline Links](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/issues/42) 80 | 81 | ### Varied Style 82 | 83 | Varied style allows for setting the topmost level of your headings, and the rest of the levels to the opposite style. 84 | 85 | For example if you have `varied_style` set to true and your list style is bullet, the first level will be bullet and the subsequent headings will be number style. 86 | 87 | **Style: Bullet** 88 | 89 | ![](media/varied-style-bullet.jpg) 90 | 91 | **Style: Number** 92 | 93 | ![](media/varied-style-number.jpg) 94 | 95 | See [Feature Request: Mixed List Style](https://github.com/Aidurber/obsidian-plugin-dynamic-toc/issues/35) 96 | 97 | ### External Rendering Support 98 | 99 | ![](media/settings.jpg) 100 | 101 | You can also add custom injection for compatibility with markdown readers such as Markor or Gitlab with the External Rendering Support setting. Such as: 102 | 103 | - `[toc]`/`[TOC]` 104 | - Or `[[_TOC_]]` 105 | 106 | This feature is to allow for consistency with another tool of your choice such as GitLab. 107 | 108 | If you have another convention that is required for a markdown reader of your choosing. Create an issue with the name of the viewer and the convention that's used. 109 | 110 | #### Support All 111 | 112 | You can skip individual selection and support all renderers by checking "Support all external renderers" in settings. 113 | 114 | > ! If you add a new line between each identifier, you will get a new table of contents for each 115 | 116 | ```markdown 117 | [/toc/] 118 | 119 | {{toc}} 120 | 121 | [[__TOC__]] 122 | 123 | [toc] 124 | ``` 125 | 126 | > ! If you add them all next to each other you will get a single block 127 | 128 | ```markdown 129 | [/toc/] 130 | {{toc}} 131 | [[__TOC__]] 132 | [toc] 133 | ``` 134 | 135 | ### Insert Command 136 | 137 | You can insert a table of contents by using the command palette and selecting "Insert table of contents" and selecting the table of contents to insert 138 | 139 | ![](media/toc-command.jpg) 140 | 141 | > Insert command 142 | 143 | ![](media/toc-command-options.jpg) 144 | 145 | > Table of contents options. 146 | > Note that you will only see: 147 | > 148 | > 1. "Code Block" if you have no external renderers set in settings 149 | > 2. "Code Block" and a single external renderer if set in settings 150 | > 3. All possible options if you have "Support all external renderers" set in settings 151 | 152 | Remember you can set a hotkey in Obsidian for this command for even faster access. 153 | 154 | ### Titles 155 | 156 | You can add a title to every injected table of contents by using the Title option in setttings or inline inside a codeblock for example: 157 | 158 | ````markdown 159 | ```toc 160 | title: "## Table of Contents" 161 | ``` 162 | ```` 163 | 164 | > ⚠️ If you are adding Markdown syntax to your title in the code block, you must wrap it in double quotes. 165 | 166 | ### Inconsistent Heading Depth 167 | 168 | As mentioned in the foreword above, this is not recommended, but there is a setting you can enable which will try and support you the best it can. 169 | 170 | Given a heading structure such as: 171 | 172 | ```md 173 | ## Level 2 174 | 175 | #### Level 4 176 | 177 | ##### Level 5 178 | 179 | ## Level 2 180 | 181 | ### Level 3 182 | ``` 183 | 184 | With this option enabled, it will produce the following table of contents: 185 | 186 | ![](media/inconsistent-heading-depth.jpg) 187 | 188 | > ⚠️ Notice that the Level 4 and Level 3 headings are at the same depth 189 | 190 | ## Contributing 191 | 192 | ```bash 193 | yarn install 194 | ``` 195 | 196 | ### Development 197 | 198 | To start building the plugin with what mode enabled run the following command: 199 | 200 | ```bash 201 | yarn dev 202 | ``` 203 | 204 | ### Releasing 205 | 206 | To start a release build run the following command: 207 | 208 | ```bash 209 | yarn release 210 | git push --follow-tags origin main 211 | ``` 212 | 213 | --- 214 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-dynamic-toc", 3 | "name": "Dynamic Table of Contents", 4 | "author": "aidurber", 5 | "description": "An Obsidian plugin to generate Tables of Contents that stay up to date with your document outline.", 6 | "minAppVersion": "0.11.0", 7 | "version": "0.0.27", 8 | "repo": "aidurber/obsidian-plugin-dynamic-toc" 9 | } 10 | -------------------------------------------------------------------------------- /media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/.gitkeep -------------------------------------------------------------------------------- /media/inconsistent-heading-depth.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/inconsistent-heading-depth.jpg -------------------------------------------------------------------------------- /media/inline-headings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/inline-headings.jpg -------------------------------------------------------------------------------- /media/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/screenshot.jpg -------------------------------------------------------------------------------- /media/settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/settings.jpg -------------------------------------------------------------------------------- /media/toc-command-options.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/toc-command-options.jpg -------------------------------------------------------------------------------- /media/toc-command.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/toc-command.jpg -------------------------------------------------------------------------------- /media/varied-style-bullet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/varied-style-bullet.jpg -------------------------------------------------------------------------------- /media/varied-style-number.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidurber/obsidian-plugin-dynamic-toc/28978d3018e7da937649a4615a758a5b55823027/media/varied-style-number.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-plugin-dynamic-toc", 3 | "version": "0.0.27", 4 | "description": "An Obsidian plugin to generate Tables of Contents that stay up to date with your document outline.", 5 | "main": "main.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "obsidian-plugin dev -S src/styles.css src/main.ts", 9 | "prebuild": "yarn test", 10 | "type-check": "tsc --noEmit", 11 | "build": "obsidian-plugin build -S src/styles.css src/main.ts -o .", 12 | "release:dry": "standard-version -t '' --dry-run", 13 | "release": "standard-version -t '' -a", 14 | "test": "jest", 15 | "test:watch": "jest --watch" 16 | }, 17 | "devDependencies": { 18 | "@types/jest": "^27.0.3", 19 | "detect-indent": "^6.1.0", 20 | "detect-newline": "^3.1.0", 21 | "jest": "^27.4.5", 22 | "obsidian": "obsidianmd/obsidian-api", 23 | "obsidian-plugin-cli": "^0.8.0", 24 | "standard-version": "^9.3.1", 25 | "stringify-package": "^1.0.1", 26 | "ts-jest": "^27.1.2", 27 | "typescript": "^4.5.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scripts/manifest-updater.js: -------------------------------------------------------------------------------- 1 | const stringifyPackage = require("stringify-package"); 2 | const detectIndent = require("detect-indent"); 3 | const detectNewline = require("detect-newline"); 4 | 5 | module.exports.readVersion = function (contents) { 6 | const data = JSON.parse(contents); 7 | return data.version; 8 | }; 9 | 10 | module.exports.writeVersion = function (contents, version) { 11 | const json = JSON.parse(contents); 12 | let indent = detectIndent(contents).indent; 13 | let newline = detectNewline(contents); 14 | json.version = version; 15 | return stringifyPackage(json, indent, newline); 16 | }; 17 | -------------------------------------------------------------------------------- /scripts/versions-updater.js: -------------------------------------------------------------------------------- 1 | const stringifyPackage = require("stringify-package"); 2 | const detectIndent = require("detect-indent"); 3 | const detectNewline = require("detect-newline"); 4 | 5 | module.exports.readVersion = function (contents) { 6 | const data = JSON.parse(contents); 7 | const keys = Object.keys(data); 8 | return keys[keys.length - 1]; 9 | }; 10 | 11 | module.exports.writeVersion = function (contents, version) { 12 | const json = JSON.parse(contents); 13 | let indent = detectIndent(contents).indent; 14 | let newline = detectNewline(contents); 15 | const values = Object.values(json); 16 | json[version] = values[values.length - 1]; 17 | 18 | const result = stringifyPackage(json, indent, newline); 19 | 20 | return result; 21 | }; 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DynamicTOCSettings, 3 | ExternalMarkdownKey, 4 | EXTERNAL_MARKDOWN_PREVIEW_STYLE, 5 | } from "./types"; 6 | 7 | export const DEFAULT_SETTINGS: DynamicTOCSettings = { 8 | style: "bullet", 9 | min_depth: 2, 10 | max_depth: 6, 11 | externalStyle: "None", 12 | supportAllMatchers: false, 13 | allow_inconsistent_headings: false, 14 | }; 15 | 16 | export const TABLE_CLASS_NAME = "dynamic-toc"; 17 | export const TABLE_CLASS_SELECTOR = `.${TABLE_CLASS_NAME}`; 18 | 19 | export const ALL_MATCHERS = Object.keys( 20 | EXTERNAL_MARKDOWN_PREVIEW_STYLE 21 | ) as ExternalMarkdownKey[]; 22 | -------------------------------------------------------------------------------- /src/insert-command.modal.ts: -------------------------------------------------------------------------------- 1 | import { App, FuzzySuggestModal } from "obsidian"; 2 | import DynamicTOCPlugin from "./main"; 3 | import { ExternalMarkdownKey } from "./types"; 4 | 5 | type OptionsCollection = Record< 6 | Exclude & "code-block", 7 | { 8 | label: string; 9 | value: string; 10 | } 11 | >; 12 | 13 | // TODO refactor to use this as external matchers value so we have a single source of truth 14 | const options: OptionsCollection = { 15 | "code-block": { value: `\`\`\`toc\n\`\`\``, label: "Code block" }, 16 | TOC: { value: "[TOC]", label: "[TOC]" }, 17 | _TOC_: { label: "__TOC__", value: "[[__TOC__]]" }, 18 | AzureWiki: { label: "_TOC_", value: "[[_TOC_]]" }, 19 | DevonThink: { label: "{{toc}}", value: "{{toc}}" }, 20 | TheBrain: { label: "[/toc/]", value: "[/toc/]" }, 21 | }; 22 | export class InsertCommandModal extends FuzzySuggestModal { 23 | private plugin: DynamicTOCPlugin; 24 | callback: (item: string) => void; 25 | constructor(app: App, plugin: DynamicTOCPlugin) { 26 | super(app); 27 | this.app = app; 28 | this.plugin = plugin; 29 | this.setPlaceholder("Type name of table of contents type..."); 30 | } 31 | getItems(): string[] { 32 | if (this.plugin.settings.supportAllMatchers) { 33 | return Object.keys(options); 34 | } 35 | if (this.plugin.settings.externalStyle !== "None") { 36 | return ["code-block", this.plugin.settings.externalStyle]; 37 | } 38 | return ["code-block"]; 39 | } 40 | getItemText(id: string): string { 41 | const foundKey = Object.keys(options).find((v) => v === id); 42 | return options[foundKey].label; 43 | } 44 | onChooseItem(item: string): void { 45 | this.callback(options[item].value); 46 | } 47 | public start(callback: (item: string) => void): void { 48 | this.callback = callback; 49 | this.open(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownPostProcessorContext, Plugin } from "obsidian"; 2 | import { parseConfig } from "./utils/config"; 3 | import { ALL_MATCHERS, DEFAULT_SETTINGS } from "./constants"; 4 | import { CodeBlockRenderer } from "./renderers/code-block-renderer"; 5 | import { DynamicTOCSettingsTab } from "./settings-tab"; 6 | import { 7 | DynamicTOCSettings, 8 | ExternalMarkdownKey, 9 | EXTERNAL_MARKDOWN_PREVIEW_STYLE, 10 | } from "./types"; 11 | import { DynamicInjectionRenderer } from "./renderers/dynamic-injection-renderer"; 12 | import { InsertCommandModal } from "./insert-command.modal"; 13 | 14 | export default class DynamicTOCPlugin extends Plugin { 15 | settings: DynamicTOCSettings; 16 | onload = async () => { 17 | await this.loadSettings(); 18 | this.addSettingTab(new DynamicTOCSettingsTab(this.app, this)); 19 | this.addCommand({ 20 | id: "dynamic-toc-insert-command", 21 | name: "Insert Table of Contents", 22 | editorCallback: (editor: Editor) => { 23 | const modal = new InsertCommandModal(this.app, this); 24 | modal.start((text: string) => { 25 | editor.setCursor(editor.getCursor().line, 0); 26 | editor.replaceSelection(text); 27 | }); 28 | }, 29 | }); 30 | this.registerMarkdownCodeBlockProcessor( 31 | "toc", 32 | (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { 33 | const options = parseConfig(source, this.settings); 34 | ctx.addChild( 35 | new CodeBlockRenderer(this.app, options, ctx.sourcePath, el) 36 | ); 37 | } 38 | ); 39 | 40 | this.registerMarkdownPostProcessor( 41 | (el: HTMLElement, ctx: MarkdownPostProcessorContext) => { 42 | const matchers = 43 | this.settings.supportAllMatchers === true 44 | ? ALL_MATCHERS 45 | : [this.settings.externalStyle]; 46 | for (let matcher of matchers as ExternalMarkdownKey[]) { 47 | if (!matcher || matcher === "None") continue; 48 | const match = DynamicInjectionRenderer.findMatch( 49 | el, 50 | EXTERNAL_MARKDOWN_PREVIEW_STYLE[matcher as ExternalMarkdownKey] 51 | ); 52 | if (!match?.parentNode) continue; 53 | ctx.addChild( 54 | new DynamicInjectionRenderer( 55 | this.app, 56 | this.settings, 57 | ctx.sourcePath, 58 | el, 59 | match 60 | ) 61 | ); 62 | } 63 | } 64 | ); 65 | }; 66 | 67 | loadSettings = async () => { 68 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 69 | }; 70 | 71 | saveSettings = async () => { 72 | await this.saveData(this.settings); 73 | this.app.metadataCache.trigger("dynamic-toc:settings", this.settings); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/models/__tests__/heading.test.ts: -------------------------------------------------------------------------------- 1 | import { HeadingCache } from "obsidian"; 2 | import { Heading } from "../heading"; 3 | 4 | describe("Heading Model", () => { 5 | it("should have the correct raw heading", () => { 6 | const heading = new Heading({ heading: "foo", level: 1 } as HeadingCache); 7 | expect(heading.rawHeading).toBe("foo"); 8 | }); 9 | 10 | describe("Link headings", () => { 11 | const heading = new Heading({ 12 | heading: "[[foo]]", 13 | level: 1, 14 | } as HeadingCache); 15 | 16 | it("should isLink should be true if is link", () => { 17 | expect(heading.isLink).toBe(true); 18 | }); 19 | it("should href should be correct", () => { 20 | expect(heading.href).toBe("#foo"); 21 | }); 22 | it("should markdownHref should be correct", () => { 23 | expect(heading.markdownHref).toBe("[[#foo]]"); 24 | }); 25 | 26 | describe("With alias", () => { 27 | const heading = new Heading({ 28 | heading: "[[Something|Alt Text]]", 29 | level: 1, 30 | } as HeadingCache); 31 | 32 | it("should isLink should be true if is link", () => { 33 | expect(heading.isLink).toBe(true); 34 | }); 35 | it("should href should be correct", () => { 36 | expect(heading.href).toBe("#Something Alt Text"); 37 | }); 38 | it("should markdownHref should be correct", () => { 39 | expect(heading.markdownHref).toBe("[[#Something Alt Text|Alt Text]]"); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/models/heading.ts: -------------------------------------------------------------------------------- 1 | import { HeadingCache } from "obsidian"; 2 | // TODO refactor this 3 | export class Heading { 4 | constructor(private cached: HeadingCache) {} 5 | 6 | get level(): number { 7 | return this.cached.level; 8 | } 9 | 10 | get rawHeading(): string { 11 | return this.cached.heading; 12 | } 13 | get isLink(): boolean { 14 | return /\[\[(.*?)\]\]/.test(this.cached.heading); 15 | } 16 | get href(): string | null { 17 | if (!this.isLink) return null; 18 | const value = this.parseMarkdownLink(this.rawHeading); 19 | const parts = value.split("|"); 20 | return `#${parts.join(" ")}`; 21 | } 22 | get markdownHref(): string | null { 23 | if (!this.isLink) return `[[#${this.rawHeading}]]`; 24 | const value = this.parseMarkdownLink(this.rawHeading); 25 | const parts = value.split("|"); 26 | const hasAlias = parts.length > 1; 27 | if (!hasAlias) { 28 | return `[[#${parts[0]}]]`; 29 | } 30 | 31 | // The way obsidian needs to render the link is to have the link be 32 | // the header + alias such as [[#Something Alt Text]] 33 | // Then we need to append the actual alias 34 | const link = parts.join(" "); 35 | return `[[#${link}|${parts[1]}]]`; 36 | } 37 | 38 | private parseMarkdownLink(link: string): string { 39 | const [, base] = link.match(/\[\[(.*?)]\]/) || []; 40 | return base; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/obsidian-ex.d.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | 3 | declare module "obsidian" { 4 | interface TFile { 5 | deleted: boolean; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/renderers/code-block-renderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | MarkdownRenderChild, 4 | MarkdownRenderer, 5 | TFile, 6 | WorkspaceLeaf, 7 | } from "obsidian"; 8 | import { mergeSettings } from "../utils/config"; 9 | import { extractHeadings } from "../utils/extract-headings"; 10 | import { DynamicTOCSettings, TableOptions } from "../types"; 11 | import { TABLE_CLASS_NAME } from "src/constants"; 12 | 13 | export class CodeBlockRenderer extends MarkdownRenderChild { 14 | constructor( 15 | private app: App, 16 | private config: TableOptions, 17 | private filePath: string, 18 | public container: HTMLElement 19 | ) { 20 | super(container); 21 | } 22 | async onload() { 23 | await this.render(); 24 | this.registerEvent( 25 | this.app.metadataCache.on( 26 | //@ts-ignore 27 | "dynamic-toc:settings", 28 | this.onSettingsChangeHandler 29 | ) 30 | ); 31 | this.registerEvent( 32 | this.app.workspace.on( 33 | "active-leaf-change", 34 | this.onActiveLeafChangeHandler 35 | ) 36 | ); 37 | this.registerEvent( 38 | this.app.metadataCache.on("changed", this.onFileChangeHandler) 39 | ); 40 | } 41 | 42 | onActiveLeafChangeHandler = (_: WorkspaceLeaf) => { 43 | const activeFile = this.app.workspace.getActiveFile(); 44 | this.filePath = activeFile.path; 45 | this.onFileChangeHandler(activeFile); 46 | }; 47 | 48 | onSettingsChangeHandler = (settings: DynamicTOCSettings) => { 49 | this.render(mergeSettings(this.config, settings)); 50 | }; 51 | onFileChangeHandler = (file: TFile) => { 52 | this.filePath = file.path; 53 | if (file.deleted) return; 54 | this.render(); 55 | }; 56 | 57 | async render(configOverride?: TableOptions) { 58 | this.container.empty(); 59 | this.container.classList.add(TABLE_CLASS_NAME); 60 | const headings = extractHeadings( 61 | this.app.metadataCache.getCache(this.filePath), 62 | configOverride || this.config 63 | ); 64 | await MarkdownRenderer.renderMarkdown( 65 | headings, 66 | this.container, 67 | this.filePath, 68 | this 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/renderers/dynamic-injection-renderer.ts: -------------------------------------------------------------------------------- 1 | import { App, MarkdownRenderChild, MarkdownRenderer, TFile } from "obsidian"; 2 | import { TABLE_CLASS_NAME, TABLE_CLASS_SELECTOR } from "src/constants"; 3 | import { DynamicTOCSettings } from "../types"; 4 | import { extractHeadings } from "../utils/extract-headings"; 5 | 6 | export class DynamicInjectionRenderer extends MarkdownRenderChild { 7 | constructor( 8 | private app: App, 9 | private settings: DynamicTOCSettings, 10 | private filePath: string, 11 | container: HTMLElement, 12 | private match: HTMLElement 13 | ) { 14 | super(container); 15 | } 16 | static findMatch(element: HTMLElement, text: string): HTMLElement | null { 17 | const match = 18 | Array.from(element.querySelectorAll("p, span, a")).find((el) => { 19 | return el.textContent.toLowerCase().includes(text.toLowerCase()); 20 | }) || null; 21 | return match as HTMLElement | null; 22 | } 23 | async onload() { 24 | this.render(); 25 | this.registerEvent( 26 | this.app.metadataCache.on( 27 | //@ts-ignore 28 | "dynamic-toc:settings", 29 | this.onSettingsChangeHandler 30 | ) 31 | ); 32 | this.registerEvent( 33 | this.app.metadataCache.on("changed", this.onFileChangeHandler) 34 | ); 35 | } 36 | 37 | onSettingsChangeHandler = () => { 38 | this.render(); 39 | }; 40 | onFileChangeHandler = (file: TFile) => { 41 | if (file.deleted || file.path !== this.filePath) return; 42 | this.render(); 43 | }; 44 | 45 | async render() { 46 | const headings = extractHeadings( 47 | this.app.metadataCache.getCache(this.filePath), 48 | this.settings 49 | ); 50 | const newElement = document.createElement("div"); 51 | newElement.classList.add(TABLE_CLASS_NAME); 52 | await MarkdownRenderer.renderMarkdown( 53 | headings, 54 | newElement, 55 | this.filePath, 56 | this 57 | ); 58 | // Keep the match in the document as a hook but hide it 59 | this.match.style.display = "none"; 60 | const existing = this.containerEl.querySelector(TABLE_CLASS_SELECTOR); 61 | // We need to keep cleaning up after ourselves on settings or file changes 62 | if (existing) { 63 | this.containerEl.removeChild(existing); 64 | } 65 | // Attach the table to the parent of the match 66 | this.match.parentNode.appendChild(newElement); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/settings-tab.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting, Notice } from "obsidian"; 2 | import { 3 | BulletStyle, 4 | ExternalMarkdownKey, 5 | EXTERNAL_MARKDOWN_PREVIEW_STYLE, 6 | } from "./types"; 7 | import DynamicTOCPlugin from "./main"; 8 | 9 | export class DynamicTOCSettingsTab extends PluginSettingTab { 10 | constructor(app: App, private plugin: DynamicTOCPlugin) { 11 | super(app, plugin); 12 | } 13 | 14 | display(): void { 15 | let { containerEl } = this; 16 | 17 | containerEl.empty(); 18 | containerEl.createEl("h2", { text: "Dynamic Table of Contents Settings" }); 19 | new Setting(containerEl) 20 | .setName("List Style") 21 | .setDesc("The table indication") 22 | .addDropdown((cb) => 23 | cb 24 | .addOptions({ bullet: "Bullet", number: "Number", inline: "Inline" }) 25 | .setValue(this.plugin.settings.style) 26 | .onChange(async (val) => { 27 | this.plugin.settings.style = val as BulletStyle; 28 | await this.plugin.saveSettings(); 29 | }) 30 | ); 31 | new Setting(containerEl) 32 | .setName("Enable varied style") 33 | .setDesc( 34 | "Varied style allows for the most top level heading to match your list style, then subsequent levels to be the opposite. For example if your list style is number, then your level 2 headings will be number, any levels lower then 2 will be bullet and vice versa." 35 | ) 36 | .addToggle((cb) => 37 | cb.setValue(this.plugin.settings.varied_style).onChange(async (val) => { 38 | this.plugin.settings.varied_style = val; 39 | await this.plugin.saveSettings(); 40 | }) 41 | ); 42 | 43 | new Setting(containerEl) 44 | .setName("Delimiter") 45 | .setDesc( 46 | "Only used when list style is inline. The delimiter between the list items" 47 | ) 48 | .addText((text) => 49 | text 50 | .setPlaceholder("e.g. -, *, ~") 51 | .setValue(this.plugin.settings.delimiter) 52 | .onChange(async (val) => { 53 | this.plugin.settings.delimiter = val; 54 | this.plugin.saveSettings(); 55 | }) 56 | ); 57 | 58 | new Setting(containerEl) 59 | .setName("Minimum Header Depth") 60 | .setDesc("The default minimum header depth to render") 61 | .addSlider((slider) => 62 | slider 63 | .setLimits(1, 6, 1) 64 | .setValue(this.plugin.settings.min_depth) 65 | .setDynamicTooltip() 66 | .onChange(async (val) => { 67 | if (val > this.plugin.settings.max_depth) { 68 | new Notice("Min Depth is higher than Max Depth"); 69 | } else { 70 | this.plugin.settings.min_depth = val; 71 | await this.plugin.saveSettings(); 72 | } 73 | }) 74 | ); 75 | new Setting(containerEl) 76 | .setName("Maximum Header Depth") 77 | .setDesc("The default maximum header depth to render") 78 | .addSlider((slider) => 79 | slider 80 | .setLimits(1, 6, 1) 81 | .setValue(this.plugin.settings.max_depth) 82 | .setDynamicTooltip() 83 | .onChange(async (val) => { 84 | if (val < this.plugin.settings.min_depth) { 85 | new Notice("Max Depth is higher than Min Depth"); 86 | } else { 87 | this.plugin.settings.max_depth = val; 88 | await this.plugin.saveSettings(); 89 | } 90 | }) 91 | ); 92 | new Setting(containerEl) 93 | .setName("Title") 94 | .setDesc( 95 | "The title of the table of contents, supports simple markdown such as ## Contents or **Contents**" 96 | ) 97 | .addText((text) => 98 | text 99 | .setPlaceholder("## Table of Contents") 100 | .setValue(this.plugin.settings.title) 101 | .onChange(async (val) => { 102 | this.plugin.settings.title = val; 103 | this.plugin.saveSettings(); 104 | }) 105 | ); 106 | const externalRendererSetting = new Setting(containerEl) 107 | .setName("External rendering support") 108 | .setDesc( 109 | "Different markdown viewers provided Table of Contents support such as [TOC] or [[_TOC_]]. You may need to restart Obsidian for this to take effect." 110 | ) 111 | .addDropdown((cb) => 112 | cb 113 | .addOptions( 114 | Object.keys(EXTERNAL_MARKDOWN_PREVIEW_STYLE).reduce( 115 | (acc, curr: keyof typeof EXTERNAL_MARKDOWN_PREVIEW_STYLE) => { 116 | const value = EXTERNAL_MARKDOWN_PREVIEW_STYLE[curr]; 117 | return { ...acc, [curr]: value }; 118 | }, 119 | {} as Record 120 | ) 121 | ) 122 | .setDisabled(this.plugin.settings.supportAllMatchers) 123 | .setValue(this.plugin.settings.externalStyle) 124 | .onChange(async (val: ExternalMarkdownKey) => { 125 | this.plugin.settings.externalStyle = val; 126 | await this.plugin.saveSettings(); 127 | }) 128 | ); 129 | 130 | new Setting(containerEl) 131 | .setName("Support all external renderers") 132 | .setDesc("Cannot be used in conjunction with individual renderers") 133 | .addToggle((cb) => 134 | cb 135 | .setValue(this.plugin.settings.supportAllMatchers) 136 | .onChange(async (val) => { 137 | this.plugin.settings.supportAllMatchers = val; 138 | externalRendererSetting.setDisabled(val); 139 | await this.plugin.saveSettings(); 140 | }) 141 | ); 142 | new Setting(containerEl) 143 | .setName("Allow inconsistent heading levels") 144 | .setDesc( 145 | "NOT RECOMMENDED (may be removed in future): If enabled, the table of contents will be generated even if the header depth is inconsistent. This may cause the table of contents to be rendered incorrectly." 146 | ) 147 | .addToggle((cb) => 148 | cb 149 | .setValue(this.plugin.settings.allow_inconsistent_headings) 150 | .onChange(async (val) => { 151 | this.plugin.settings.allow_inconsistent_headings = val; 152 | await this.plugin.saveSettings(); 153 | }) 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .is-live-preview .dynamic-toc > * { 2 | margin-top: 0px; 3 | margin-bottom: 0px; 4 | } 5 | 6 | .is-live-preview .dynamic-toc > * br { 7 | display: none; 8 | } 9 | 10 | .is-live-preview .dynamic-toc > *:first-child { 11 | margin-top: 16px; 12 | } 13 | 14 | .is-live-preview .dynamic-toc > *:last-child { 15 | margin-bottom: 16px; 16 | } 17 | 18 | .is-live-preview .dynamic-toc ul { 19 | white-space: normal; 20 | } 21 | 22 | .is-live-preview .dynamic-toc ol { 23 | white-space: normal; 24 | } 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type BulletStyle = "bullet" | "number" | "inline"; 2 | export interface TableOptions { 3 | style: BulletStyle; 4 | min_depth: number; 5 | max_depth: number; 6 | title?: string; 7 | allow_inconsistent_headings: boolean; 8 | delimiter?: string; 9 | varied_style?: boolean; 10 | } 11 | 12 | export const EXTERNAL_MARKDOWN_PREVIEW_STYLE = { 13 | None: "", 14 | TOC: "[TOC]", 15 | _TOC_: "__TOC__", 16 | AzureWiki: "_TOC_", 17 | DevonThink: "{{toc}}", 18 | TheBrain: "[/toc/]", 19 | }; 20 | 21 | export type ExternalMarkdownKey = keyof typeof EXTERNAL_MARKDOWN_PREVIEW_STYLE; 22 | export interface DynamicTOCSettings extends TableOptions { 23 | externalStyle: ExternalMarkdownKey; 24 | supportAllMatchers: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/__tests__/__snapshots__/extract-headings.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Extract headings build markdown text should match snapshot 1`] = ` 4 | "1. [[#foo]] 5 | 1. [[#bar]] 6 | 1. [[#baz]] 7 | 1. [[#Something Alt Text|Alt Text]] 8 | 1. [[#level 1]] 9 | 1. [[#level 1 a]]" 10 | `; 11 | 12 | exports[`Extract headings build markdown text should match snapshot when varied_style is true and style is bullet 1`] = ` 13 | "- [[#foo]] 14 | 1. [[#bar]] 15 | 1. [[#baz]] 16 | 1. [[#Something Alt Text|Alt Text]] 17 | - [[#level 1]] 18 | 1. [[#level 1 a]]" 19 | `; 20 | 21 | exports[`Extract headings build markdown text should match snapshot when varied_style is true and style is number 1`] = ` 22 | "1. [[#foo]] 23 | - [[#bar]] 24 | - [[#baz]] 25 | - [[#Something Alt Text|Alt Text]] 26 | 1. [[#level 1]] 27 | - [[#level 1 a]]" 28 | `; 29 | 30 | exports[`Extract headings build markdown text should match snapshot with inconsistent heading levels 1`] = ` 31 | "1. [[#Level 2]] 32 | 1. [[#Level 4]] 33 | 1. [[#Level 2]] 34 | 1. [[#Level 3]]" 35 | `; 36 | 37 | exports[`Extract headings build markdown text should match snapshot with title 1`] = ` 38 | "## Table of Contents 39 | 1. [[#foo]] 40 | 1. [[#bar]] 41 | 1. [[#baz]] 42 | 1. [[#Something Alt Text|Alt Text]] 43 | 1. [[#level 1]] 44 | 1. [[#level 1 a]]" 45 | `; 46 | -------------------------------------------------------------------------------- /src/utils/__tests__/extract-headings.test.ts: -------------------------------------------------------------------------------- 1 | import { CachedMetadata } from "obsidian"; 2 | import { TableOptions } from "src/types"; 3 | import { extractHeadings } from "../extract-headings"; 4 | 5 | describe("Extract headings", () => { 6 | describe("build markdown text", () => { 7 | const defaultHeadings = { 8 | headings: [ 9 | { 10 | heading: "foo", 11 | level: 1, 12 | }, 13 | { 14 | heading: "bar", 15 | level: 2, 16 | }, 17 | { 18 | heading: "baz", 19 | level: 3, 20 | }, 21 | { 22 | heading: "[[Something|Alt Text]]", 23 | level: 4, 24 | }, 25 | { 26 | heading: "level 1", 27 | level: 1, 28 | }, 29 | { 30 | heading: "level 1 a", 31 | level: 2, 32 | }, 33 | ], 34 | } as CachedMetadata; 35 | it("should match snapshot", () => { 36 | const options = { 37 | max_depth: 4, 38 | min_depth: 1, 39 | style: "number", 40 | } as TableOptions; 41 | expect(extractHeadings(defaultHeadings, options)).toMatchSnapshot(); 42 | }); 43 | 44 | it("should match snapshot when varied_style is true and style is bullet", () => { 45 | const options = { 46 | max_depth: 4, 47 | min_depth: 1, 48 | style: "bullet", 49 | varied_style: true, 50 | } as TableOptions; 51 | expect(extractHeadings(defaultHeadings, options)).toMatchSnapshot(); 52 | }); 53 | it("should match snapshot when varied_style is true and style is number", () => { 54 | const options = { 55 | max_depth: 4, 56 | min_depth: 1, 57 | style: "number", 58 | varied_style: true, 59 | } as TableOptions; 60 | expect(extractHeadings(defaultHeadings, options)).toMatchSnapshot(); 61 | }); 62 | 63 | it("should match snapshot with title", () => { 64 | const options = { 65 | max_depth: 4, 66 | min_depth: 1, 67 | style: "number", 68 | title: "## Table of Contents", 69 | } as TableOptions; 70 | expect(extractHeadings(defaultHeadings, options)).toMatchSnapshot(); 71 | }); 72 | 73 | it("should match snapshot with inconsistent heading levels", () => { 74 | const fileMetaData = { 75 | headings: [ 76 | { 77 | heading: "Level 2", 78 | level: 2, 79 | }, 80 | { 81 | heading: "Level 4", 82 | level: 4, 83 | }, 84 | { 85 | heading: "Level 5", 86 | level: 5, 87 | }, 88 | { 89 | heading: "Level 2", 90 | level: 2, 91 | }, 92 | { 93 | heading: "Level 3", 94 | level: 3, 95 | }, 96 | ], 97 | } as CachedMetadata; 98 | const options = { 99 | max_depth: 4, 100 | min_depth: 1, 101 | style: "number", 102 | allow_inconsistent_headings: true, 103 | } as TableOptions; 104 | expect(extractHeadings(fileMetaData, options)).toMatchSnapshot(); 105 | }); 106 | }); 107 | describe("build inline markdown text", () => { 108 | const defaultHeadings = { 109 | headings: [ 110 | { 111 | heading: "foo", 112 | level: 2, 113 | }, 114 | { 115 | heading: "bar", 116 | level: 3, 117 | }, 118 | { 119 | heading: "baz", 120 | level: 2, 121 | }, 122 | { 123 | heading: "[[Something|Alt Text]]", 124 | level: 2, 125 | }, 126 | ], 127 | } as CachedMetadata; 128 | it("should render correct markdown", () => { 129 | const options = { 130 | max_depth: 4, 131 | min_depth: 1, 132 | style: "inline", 133 | } as TableOptions; 134 | const result = extractHeadings(defaultHeadings, options); 135 | expect(result).toEqual( 136 | "[[#foo]] | [[#baz]] | [[#Something Alt Text|Alt Text]]" 137 | ); 138 | }); 139 | it("should accept a different delimiter", () => { 140 | const options = { 141 | max_depth: 4, 142 | min_depth: 1, 143 | style: "inline", 144 | delimiter: "*", 145 | } as TableOptions; 146 | const result = extractHeadings(defaultHeadings, options); 147 | expect(result).toEqual( 148 | "[[#foo]] * [[#baz]] * [[#Something Alt Text|Alt Text]]" 149 | ); 150 | }); 151 | it("should trim user delimiter", () => { 152 | const options = { 153 | max_depth: 4, 154 | min_depth: 1, 155 | style: "inline", 156 | delimiter: " * ", 157 | } as TableOptions; 158 | const result = extractHeadings(defaultHeadings, options); 159 | expect(result).toEqual( 160 | "[[#foo]] * [[#baz]] * [[#Something Alt Text|Alt Text]]" 161 | ); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { parseYaml } from "obsidian"; 2 | import { DynamicTOCSettings, TableOptions } from "../types"; 3 | /** 4 | * Merge settings and codeblock options taking truthy values 5 | * @param options - Code block options 6 | * @param settings - Plugin settings 7 | * @returns 8 | */ 9 | export function mergeSettings( 10 | options: TableOptions, 11 | settings: DynamicTOCSettings 12 | ): TableOptions { 13 | const merged = Object.assign({}, settings, options); 14 | return Object.keys(merged).reduce((acc, curr: keyof TableOptions) => { 15 | const value = options[curr]; 16 | const isEmptyValue = typeof value === "undefined" || value === null; 17 | return { 18 | ...acc, 19 | [curr]: isEmptyValue ? settings[curr] : value, 20 | }; 21 | }, {} as TableOptions); 22 | } 23 | /** 24 | * Parse the YAML source and merge it with plugin settings 25 | * @param source - Code block YAML source 26 | * @param settings - Plugin settings 27 | * @returns 28 | */ 29 | export function parseConfig( 30 | source: string, 31 | settings: DynamicTOCSettings 32 | ): TableOptions { 33 | try { 34 | const options = parseYaml(source) as TableOptions; 35 | return mergeSettings(options, settings); 36 | } catch (error) { 37 | return settings; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/extract-headings.ts: -------------------------------------------------------------------------------- 1 | import { CachedMetadata } from "obsidian"; 2 | import { Heading } from "../models/heading"; 3 | import { TableOptions } from "../types"; 4 | 5 | export function extractHeadings( 6 | fileMetaData: CachedMetadata, 7 | options: TableOptions 8 | ) { 9 | if (!fileMetaData?.headings) return ""; 10 | const { headings } = fileMetaData; 11 | const processableHeadings = headings.filter( 12 | (h) => !!h && h.level >= options.min_depth && h.level <= options.max_depth 13 | ); 14 | if (!processableHeadings.length) return ""; 15 | 16 | const headingInstances = processableHeadings.map((h) => new Heading(h)); 17 | if (options.style === "inline") { 18 | return buildInlineMarkdownText(headingInstances, options); 19 | } 20 | 21 | return buildMarkdownText(headingInstances, options); 22 | } 23 | 24 | function getIndicator( 25 | heading: Heading, 26 | firstLevel: number, 27 | options: TableOptions 28 | ) { 29 | const defaultIndicator = (options.style === "number" && "1.") || "-"; 30 | if (!options.varied_style) return defaultIndicator; 31 | // if the heading is at the same level as the first heading and varied_style is true, then only set the first indicator to the selected style 32 | if (heading.level === firstLevel) return defaultIndicator; 33 | return options.style === "number" ? "-" : "1."; 34 | } 35 | 36 | /** 37 | * Generate markdown for a standard table of contents 38 | * @param headings - Array of heading instances 39 | * @param options - Code block options 40 | * @returns 41 | */ 42 | function buildMarkdownText(headings: Heading[], options: TableOptions): string { 43 | const firstHeadingDepth = headings[0].level; 44 | const list: string[] = []; 45 | if (options.title) { 46 | list.push(`${options.title}`); 47 | } 48 | 49 | let previousIndent = 0; 50 | for (let i = 0; i < headings.length; i++) { 51 | const heading = headings[i]; 52 | 53 | const itemIndication = getIndicator(heading, firstHeadingDepth, options); 54 | let numIndents = new Array(Math.max(0, heading.level - firstHeadingDepth)); 55 | 56 | if (options.allow_inconsistent_headings) { 57 | if (numIndents.length - previousIndent > 1) { 58 | numIndents = new Array(previousIndent + 1); 59 | } 60 | previousIndent = numIndents.length; 61 | } 62 | 63 | const indent = numIndents.fill("\t").join(""); 64 | list.push(`${indent}${itemIndication} ${heading.markdownHref}`); 65 | } 66 | return list.join("\n"); 67 | } 68 | 69 | /** 70 | * Generate the markdown for the inline style 71 | * @param headings - Array of heading instances 72 | * @param options - Code block options 73 | * @returns 74 | */ 75 | function buildInlineMarkdownText(headings: Heading[], options: TableOptions) { 76 | const highestDepth = headings 77 | .map((h) => h.level) 78 | .reduce((a, b) => Math.min(a, b)); 79 | // all headings at the same level as the highest depth 80 | const topLevelHeadings = headings.filter( 81 | (heading) => heading.level === highestDepth 82 | ); 83 | const delimiter = options.delimiter ? options.delimiter : "|"; 84 | return topLevelHeadings 85 | .map((heading) => `${heading.markdownHref}`) 86 | .join(` ${delimiter.trim()} `); 87 | } 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "suppressImplicitAnyIndexErrors": true, 11 | "allowJs": true, 12 | "noImplicitAny": true, 13 | "moduleResolution": "node", 14 | "importHelpers": true, 15 | "lib": [ 16 | "dom", 17 | "es5", 18 | "scripthost", 19 | "es2015" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | // Empty declaration to allow for css imports 2 | declare module "*.css" {} 3 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.11.0", 3 | "0.0.2": "0.11.0", 4 | "0.0.3": "0.11.0", 5 | "0.0.4": "0.11.0", 6 | "0.0.5": "0.11.0", 7 | "0.0.6": "0.11.0", 8 | "0.0.7": "0.11.0", 9 | "0.0.8": "0.11.0", 10 | "0.0.9": "0.11.0", 11 | "0.0.10": "0.11.0", 12 | "0.0.11": "0.11.0", 13 | "0.0.12": "0.11.0", 14 | "0.0.13": "0.11.0", 15 | "0.0.14": "0.11.0", 16 | "0.0.15": "0.11.0", 17 | "0.0.16": "0.11.0", 18 | "0.0.17": "0.11.0", 19 | "0.0.18": "0.11.0", 20 | "0.0.19": "0.11.0", 21 | "0.0.20": "0.11.0", 22 | "0.0.21": "0.11.0", 23 | "0.0.22": "0.11.0", 24 | "0.0.23": "0.11.0", 25 | "0.0.24": "0.11.0", 26 | "0.0.25": "0.11.0", 27 | "0.0.26": "0.11.0", 28 | "0.0.27": "0.11.0" 29 | } 30 | --------------------------------------------------------------------------------