vault backup: 2025-04-11 14:41:26

This commit is contained in:
SeedList
2025-04-11 14:41:27 +08:00
parent eb4274e3e8
commit e1a3e955af
19 changed files with 4484 additions and 1 deletions

View File

@ -6,5 +6,6 @@
"file-explorer-note-count", "file-explorer-note-count",
"obsidian-git", "obsidian-git",
"better-export-pdf", "better-export-pdf",
"hidden-folder-obsidian" "hidden-folder-obsidian",
"automatic-table-of-contents"
] ]

View File

@ -136,5 +136,14 @@
], ],
"key": "[" "key": "["
} }
],
"automatic-table-of-contents:insert-automatic-table-of-contents-docs": [
{
"modifiers": [
"Mod",
"Shift"
],
"key": "D"
}
] ]
} }

View File

@ -0,0 +1,17 @@
---
name: Bug report
about: Create a report to help improve the plugin
title: ''
labels: bug
assignees: johansatge
---
_Hello! Please follow the checklist below when filling a bug report_
- [ ] Check in the readme if the bug is linked to a documented limitation
- [ ] Describe what the bug is
- [ ] Check if the bug can be reproduced in the _Obsidian Sandbox_ vault, or with other plugins disabled
- [ ] Paste a Markdown block that helps reproducing the described issue
- [ ] Upload a screenshot with the rendered result if relevant
- [ ] Describe the environment (OS, Obsidian version, installed plugins)

View File

@ -0,0 +1,15 @@
---
name: Feature request
about: Suggest an idea for this plugin
title: ''
labels: enhancement
assignees: johansatge
---
_Hello! Please follow the checklist below when filling a feature request_
- [ ] Check in the readme if your feature request is linked to a documented limitation
- [ ] Is your feature request related to a problem?
- [ ] Describe the solution you'd like
- [ ] Provide a Markdown block that helps describing your request, if relevant

View File

@ -0,0 +1,27 @@
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 1
strategy:
matrix:
node-version: [18.x]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v3
with:
key: ${{ runner.os }}-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
path: node_modules
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Run tests
run: npm test

View File

@ -0,0 +1,2 @@
# https://github.com/pjeby/hot-reload
.hotreload

View File

@ -0,0 +1,21 @@
# Contributing
First off, thanks for taking the time to contribute! :tada:
## Bugs and questions
Report bugs, ask questions, and request features using [GitHub Issues](https://github.com/johansatge/obsidian-automatic-table-of-contents/issues).
When submitting a bug report, do not hesitate to be as exhaustive as possible, by adding:
* A quick summary of the bug
* The expected and actual behavior
* The platform you are using (operating system, Obsidian version...)
* A Markdown sample
* Any other information that you think would be useful
## Working on the project
When contributing to this repository, please create an issue first, so the change you wish to make can be discussed with the other maintainers.
When you submit code changes, your submissions are understood to be under the same license that covers the project. Feel free to contact the maintainers if that's a concern.

View File

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright © 2023 Johan Satgé
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.

View File

@ -0,0 +1,118 @@
# Obsidian Automatic Table Of Contents
[![Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22automatic-table-of-contents%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json)](https://obsidian.md/plugins?search=automatic%20table%20of%20contents)
[![Version](https://img.shields.io/github/v/release/johansatge/obsidian-automatic-table-of-contents)](https://github.com/johansatge/obsidian-automatic-table-of-contents/releases)
[![Test](https://github.com/johansatge/obsidian-automatic-table-of-contents/actions/workflows/test.yml/badge.svg)](https://github.com/johansatge/obsidian-automatic-table-of-contents/actions)
> An Obsidian plugin to create a table of contents in a note, that updates itself when the note changes
---
![demo](images/demo.gif)
- [Installation](#installation)
- [From Obsidian (easiest)](#from-obsidian-easiest)
- [From git](#from-git)
- [From source](#from-source)
- [Usage and options](#usage-and-options)
- [Limitations and known bugs](#limitations-and-known-bugs)
- [Publish a new version](#publish-a-new-version)
- [Changelog](#changelog)
- [License](#license)
- [Contributing](#contributing)
## Installation
### From Obsidian (easiest)
Install the plugin from the [Community plugins](https://obsidian.md/plugins?search=automatic%20table%20of%20contents) section in the app settings.
### From git
Clone the plugin in your `.obsidian/plugins` directory:
```shell
cd /path/to/your/vault/.obsidian/plugins
git clone git@github.com:johansatge/obsidian-automatic-table-of-contents.git
```
### From source
Download the [latest release](https://github.com/johansatge/obsidian-automatic-table-of-contents/releases) and unzip it in the `.obsidian/plugins/automatic-table-of-contents` directory.
## Usage and options
Insert a codeblock with the `table-of-contents` (or its short version `toc`) syntax.
````
```table-of-contents
option1: value1
option2: value2
```
````
Alternatively, two commands are available in the command palette:
- Insert table of contents
- Insert table of contents (with available options)
The following options are available:
| Option | Default value | Description |
| --- | --- | --- |
| `title` | _None_ | Title to display before the table of contents (supports Markdown) |
| `style` | `nestedList` | Table of contents style (can be `nestedList`, `nestedOrderedList` or `inlineFirstLevel`) |
| `minLevel` | `0` | Include headings from the specified level (`0` for no limit) |
| `maxLevel` | `0` | Include headings up to the specified level (`0` for no limit) |
| `includeLinks` | `true` | Make headings clickable |
| `debugInConsole` | `false` | Print debug info in Obsidian console |
## Limitations and known bugs
- The table of contents may not be generated correctly if the document doesn't implement a correct titles hierarchy (level 1 title then level 3 without a level 2 in between, for instance)
- HTML & markdown that may be in the document headings are stripped when `includeLinks: true` (see [#24](https://github.com/johansatge/obsidian-automatic-table-of-contents/issues/24) & [#27](https://github.com/johansatge/obsidian-automatic-table-of-contents/issues/27))
- LaTeX equations are not rendered correctly when `includeLinks: true` (see [#13](https://github.com/johansatge/obsidian-automatic-table-of-contents/issues/13))
- Links might not behave correctly if the same title is present several times on the page (see [#57](https://github.com/johansatge/obsidian-automatic-table-of-contents/issues/57))
## Publish a new version
- Push a commit with the new version number as message with:
- The relevant changelog in `README.md`
- The new version number in `manifest.json`
- Tag the commit with the version number
- Publish a [new GitHub release](https://github.com/johansatge/obsidian-automatic-table-of-contents/releases/new) with:
- The version number as title
- The changelog from `README.md` as description
- `main.js` and `manifest.json` as attachments
- _Set as the latest release_ checked
## Changelog
This project uses [semver](http://semver.org/).
| Version | Date | Notes |
| --- | --- | --- |
| `1.5.1` | 2025-01-18 | Rename command for readability (fix #53) |
| `1.5.0` | 2024-11-24 | Add `hideWhenEmpty` option (fix #51) |
| `1.4.0` | 2024-05-19 | Add `nestedOrderedList` style ([@bjtho08](https://github.com/bjtho08)) (fix #41) |
| `1.3.3` | 2024-05-16 | Compute the right min level when `style:inlineFirstLevel` (fix #39) |
| `1.3.2` | 2024-02-18 | Harden headings stripping |
| `1.3.1` | 2024-02-18 | Strip headings for readability (fix #24, #27) |
| `1.3.0` | 2024-02-17 | Introduce `title` option (fix #5, #32) |
| `1.2.0` | 2024-01-19 | Introduce `toc` shorthand trigger (fix #19) |
| `1.1.0` | 2024-01-03 | Introduce `minLevel` option ([@ras0q](https://github.com/ras0q)) (fix #11) |
| `1.0.6` | 2023-11-02 | Escape special characters (fix #3) |
| `1.0.5` | 2023-11-01 | Fix plugin activation on mobile (fix #17) |
| `1.0.4` | 2023-10-31 | Support pages with no first level headings (fix #6) |
| `1.0.3` | 2023-09-30 | Fix readme |
| `1.0.2` | 2023-09-25 | Fix output sometimes displaying `undefined` headings |
| `1.0.1` | 2023-09-09 | Fix reference to global `App` instance |
| `1.0.0` | 2023-08-27 | Initial version |
## License
This project is released under the [MIT License](LICENSE).
## Contributing
Bug reports and feature requests are welcome! More details in the [contribution guidelines](CONTRIBUTING.md).

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,301 @@
let Plugin = class {}
let MarkdownRenderer = {}
let MarkdownRenderChild = class {}
let htmlToMarkdown = (html) => html
if (isObsidian()) {
const obsidian = require('obsidian')
Plugin = obsidian.Plugin
MarkdownRenderer = obsidian.MarkdownRenderer
MarkdownRenderChild = obsidian.MarkdownRenderChild
htmlToMarkdown = obsidian.htmlToMarkdown
}
const codeblockId = 'table-of-contents'
const codeblockIdShort = 'toc'
const availableOptions = {
title: {
type: 'string',
default: '',
comment: '',
},
style: {
type: 'value',
default: 'nestedList',
values: ['nestedList', 'nestedOrderedList', 'inlineFirstLevel'],
comment: 'TOC style (nestedList|nestedOrderedList|inlineFirstLevel)',
},
minLevel: {
type: 'number',
default: 0,
comment: 'Include headings from the specified level',
},
maxLevel: {
type: 'number',
default: 0,
comment: 'Include headings up to the specified level',
},
includeLinks: {
type: 'boolean',
default: true,
comment: 'Make headings clickable',
},
hideWhenEmpty: {
type: 'boolean',
default: false,
comment: 'Hide TOC if no headings are found'
},
debugInConsole: {
type: 'boolean',
default: false,
comment: 'Print debug info in Obsidian console',
},
}
class ObsidianAutomaticTableOfContents extends Plugin {
async onload() {
const handler = (sourceText, element, context) => {
context.addChild(new Renderer(this.app, element, context.sourcePath, sourceText))
}
this.registerMarkdownCodeBlockProcessor(codeblockId, handler)
this.registerMarkdownCodeBlockProcessor(codeblockIdShort, handler)
this.addCommand({
id: 'insert-automatic-table-of-contents',
name: 'Insert table of contents',
editorCallback: onInsertToc,
})
this.addCommand({
id: 'insert-automatic-table-of-contents-docs',
name: 'Insert table of contents (with available options)',
editorCallback: onInsertTocWithDocs,
})
}
}
function onInsertToc(editor) {
const markdown = '```' + codeblockId + '\n```'
editor.replaceRange(markdown, editor.getCursor())
}
function onInsertTocWithDocs(editor) {
let markdown = ['```' + codeblockId]
Object.keys(availableOptions).forEach((optionName) => {
const option = availableOptions[optionName]
const comment = option.comment.length > 0 ? ` # ${option.comment}` : ''
markdown.push(`${optionName}: ${option.default}${comment}`)
})
markdown.push('```')
editor.replaceRange(markdown.join('\n'), editor.getCursor())
}
class Renderer extends MarkdownRenderChild {
constructor(app, element, sourcePath, sourceText) {
super(element)
this.app = app
this.element = element
this.sourcePath = sourcePath
this.sourceText = sourceText
}
// Render on load
onload() {
this.render()
this.registerEvent(this.app.metadataCache.on('changed', this.onMetadataChange.bind(this)))
}
// Render on file change
onMetadataChange() {
this.render()
}
render() {
try {
const options = parseOptionsFromSourceText(this.sourceText)
if (options.debugInConsole) debug('Options', options)
const metadata = this.app.metadataCache.getCache(this.sourcePath)
const headings = metadata && metadata.headings ? metadata.headings : []
if (options.debugInConsole) debug('Headings', headings)
const markdown = getMarkdownFromHeadings(headings, options)
if (options.debugInConsole) debug('Markdown', markdown)
this.element.empty()
MarkdownRenderer.renderMarkdown(markdown, this.element, this.sourcePath, this)
} catch(error) {
const readableError = `_💥 Could not render table of contents (${error.message})_`
MarkdownRenderer.renderMarkdown(readableError, this.element, this.sourcePath, this)
}
}
}
function getMarkdownFromHeadings(headings, options) {
const markdownHandlersByStyle = {
nestedList: getMarkdownNestedListFromHeadings,
nestedOrderedList: getMarkdownNestedOrderedListFromHeadings,
inlineFirstLevel: getMarkdownInlineFirstLevelFromHeadings,
}
let titleMarkdown = ''
if (options.title && options.title.length > 0) {
titleMarkdown += options.title + '\n'
}
const markdownHeadings = markdownHandlersByStyle[options.style](headings, options)
if (markdownHeadings === null) {
if (options.hideWhenEmpty) {
return ''
}
return titleMarkdown + '_Table of contents: no headings found_'
}
return titleMarkdown + markdownHeadings
}
function getMarkdownNestedListFromHeadings(headings, options) {
return getMarkdownListFromHeadings(headings, false, options)
}
function getMarkdownNestedOrderedListFromHeadings(headings, options) {
return getMarkdownListFromHeadings(headings, true, options)
}
function getMarkdownListFromHeadings(headings, isOrdered, options) {
const prefix = isOrdered ? '1.' : '-'
const lines = []
const minLevel = options.minLevel > 0
? options.minLevel
: Math.min(...headings.map((heading) => heading.level))
headings.forEach((heading) => {
if (heading.level < minLevel) return
if (options.maxLevel > 0 && heading.level > options.maxLevel) return
lines.push(`${'\t'.repeat(heading.level - minLevel)}${prefix} ${getMarkdownHeading(heading, options)}`)
})
return lines.length > 0 ? lines.join('\n') : null
}
function getMarkdownInlineFirstLevelFromHeadings(headings, options) {
const minLevel = options.minLevel > 0
? options.minLevel
: Math.min(...headings.map((heading) => heading.level))
const items = headings
.filter((heading) => heading.level === minLevel)
.map((heading) => {
return getMarkdownHeading(heading, options)
})
return items.length > 0 ? items.join(' | ') : null
}
function getMarkdownHeading(heading, options) {
const stripMarkdown = (text) => {
text = text.replaceAll('*', '').replaceAll('_', '').replaceAll('`', '')
text = text.replaceAll('==', '').replaceAll('~~', '')
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Strip markdown links
return text
}
const stripHtml = (text) => stripMarkdown(htmlToMarkdown(text))
const stripWikilinks = (text, isForLink) => {
// Strip [[link|text]] format
// For the text part of the final link we only keep "text"
// For the link part we need the text + link
// Example: "# Some [[file.md|heading]]" must be translated to "[[#Some file.md heading|Some heading]]"
text = text.replace(/\[\[([^\]]+)\|([^\]]+)\]\]/g, isForLink ? '$1 $2' : '$2')
text = text.replace(/\[\[([^\]]+)\]\]/g, '$1') // Strip [[link]] format
// Replace malformed links & reserved wikilinks chars
text = text.replaceAll('[[', '').replaceAll('| ', isForLink ? '' : '- ').replaceAll('|', isForLink ? ' ' : '-')
return text
}
const stripTags = (text) => text.replaceAll('#', '')
if (options.includeLinks) {
// Remove markdown, HTML & wikilinks from text for readability, as they are not rendered in a wikilink
let text = heading.heading
text = stripMarkdown(text)
text = stripHtml(text)
text = stripWikilinks(text, false)
// Remove wikilinks & tags from link or it won't be clickable (on the other hand HTML & markdown must stay)
let link = heading.heading
link = stripWikilinks(link, true)
link = stripTags(link)
// Return wiklink style link
return `[[#${link}|${text}]]`
// Why not markdown links? Because even if it looks like the text part would have a better compatibility
// with complex headings (as it would support HTML, markdown, etc) the link part is messy,
// because it requires some encoding that looks buggy and undocumented; official docs state the link must be URL encoded
// (https://help.obsidian.md/Linking+notes+and+files/Internal+links#Supported+formats+for+internal+links)
// but it doesn't work properly, example: "## Some <em>heading</em> with simple HTML" must be encoded as:
// [Some <em>heading</em> with simple HTML](#Some%20<em>heading</em>%20with%20simpler%20HTML)
// and not
// [Some <em>heading</em> with simple HTML](#Some%20%3Cem%3Eheading%3C%2Fem%3E%20with%20simpler%20HTML)
// Also it won't be clickable at all if the heading contains #tags or more complex HTML
// (example: ## Some <em style="background: red">heading</em> #with-a-tag)
// (unless there is a way to encode these use cases that I didn't find)
}
return heading.heading
}
function parseOptionsFromSourceText(sourceText = '') {
const options = {}
Object.keys(availableOptions).forEach((option) => {
options[option] = availableOptions[option].default
})
sourceText.split('\n').forEach((line) => {
const option = parseOptionFromSourceLine(line)
if (option !== null) {
options[option.name] = option.value
}
})
return options
}
function parseOptionFromSourceLine(line) {
const matches = line.match(/([a-zA-Z0-9._ ]+):(.*)/)
if (line.startsWith('#') || !matches) return null
const possibleName = matches[1].trim()
const optionParams = availableOptions[possibleName]
let possibleValue = matches[2].trim()
if (!optionParams || optionParams.type !== 'string') {
// Strip comments from values except for strings (as a string may contain markdown)
possibleValue = possibleValue.replace(/#[^#]*$/, '').trim()
}
const valueError = new Error(`Invalid value for \`${possibleName}\``)
if (optionParams && optionParams.type === 'number') {
const value = parseInt(possibleValue)
if (value < 0) throw valueError
return { name: possibleName, value }
}
if (optionParams && optionParams.type === 'boolean') {
if (!['true', 'false'].includes(possibleValue)) throw valueError
return { name: possibleName, value: possibleValue === 'true' }
}
if (optionParams && optionParams.type === 'value') {
if (!optionParams.values.includes(possibleValue)) throw valueError
return { name: possibleName, value: possibleValue }
}
if (optionParams && optionParams.type === 'string') {
return { name: possibleName, value: possibleValue }
}
return null
}
function debug(type, data) {
console.log(...[
`%cAutomatic Table Of Contents %c${type}:\n`,
'color: orange; font-weight: bold',
'font-weight: bold',
data,
])
}
function isObsidian() {
if (typeof process !== 'object') {
return true // Obsidian mobile doesn't have a global process object
}
return !process.env || !process.env.JEST_WORKER_ID // Jest runtime is not Obsidian
}
if (isObsidian()) {
module.exports = ObsidianAutomaticTableOfContents
} else {
module.exports = {
parseOptionsFromSourceText,
getMarkdownFromHeadings,
}
}

View File

@ -0,0 +1,10 @@
{
"id": "automatic-table-of-contents",
"name": "Automatic Table Of Contents",
"version": "1.5.1",
"minAppVersion": "1.3.0",
"description": "Create a table of contents in a note, that updates itself when the note changes",
"author": "Johan Satgé",
"authorUrl": "https://github.com/johansatge",
"isDesktopOnly": false
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
{
"private": true,
"scripts": {
"test": "jest --config=test/jest.config.js"
},
"devDependencies": {
"jest": "^29.7.0"
}
}

View File

@ -0,0 +1,218 @@
const {
getMarkdownFromHeadings,
parseOptionsFromSourceText,
} = require('../main.js')
const testStandardHeadings = [
{ heading: 'Title 1 level 1', level: 1 },
{ heading: 'Title 1 level 2', level: 2 },
{ heading: 'Title 1 level 3', level: 3 },
{ heading: 'Title 2 level 1', level: 1 },
{ heading: 'Title 3 level 1', level: 1 },
{ heading: 'Title 3 level 2', level: 2 },
]
const testHeadingsWithoutFirstLevel = [
{ heading: 'Title 1 level 2', level: 2 },
{ heading: 'Title 1 level 3', level: 3 },
{ heading: 'Title 1 level 4', level: 4 },
{ heading: 'Title 2 level 2', level: 2 },
{ heading: 'Title 3 level 2', level: 2 },
{ heading: 'Title 3 level 3', level: 3 },
]
const testHeadingsWithSpecialChars = [
{ heading: 'Title 1 `level 1` {with special chars}, **bold**, _italic_, #a-tag, ==highlighted== and ~~strikethrough~~ text', level: 1 },
{ heading: 'Title 1 level 2 <em style="color: black">with HTML</em>', level: 2 },
{ heading: 'Title 1 level 2 [[wikilink1]] [[wikilink2|wikitext2]] [mdlink](https://mdurl)', level: 2 },
{ heading: 'Title 1 level 2 [[malformedlink a pi|pe | and [other chars]', level: 2 },
]
describe('Headings', () => {
test('Returns default message if no headings', () => {
const options = parseOptionsFromSourceText('')
const md = getMarkdownFromHeadings([], options)
expect(md).toContain('no headings found')
})
test('Returns empty TOC if no headings & option enabled', () => {
const options = parseOptionsFromSourceText('')
options.hideWhenEmpty = true
const md = getMarkdownFromHeadings([], options)
expect(md).toEqual('')
})
test('Returns indented list with links', () => {
const options = parseOptionsFromSourceText('')
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
- [[#Title 1 level 1|Title 1 level 1]]
- [[#Title 1 level 2|Title 1 level 2]]
- [[#Title 1 level 3|Title 1 level 3]]
- [[#Title 2 level 1|Title 2 level 1]]
- [[#Title 3 level 1|Title 3 level 1]]
- [[#Title 3 level 2|Title 3 level 2]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list with links if no first level', () => {
const options = parseOptionsFromSourceText('')
const md = getMarkdownFromHeadings(testHeadingsWithoutFirstLevel, options)
const expectedMd = sanitizeMd(`
- [[#Title 1 level 2|Title 1 level 2]]
- [[#Title 1 level 3|Title 1 level 3]]
- [[#Title 1 level 4|Title 1 level 4]]
- [[#Title 2 level 2|Title 2 level 2]]
- [[#Title 3 level 2|Title 3 level 2]]
- [[#Title 3 level 3|Title 3 level 3]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented ordered list with links', () => {
const options = parseOptionsFromSourceText('')
options.style = 'nestedOrderedList'
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
1. [[#Title 1 level 1|Title 1 level 1]]
1. [[#Title 1 level 2|Title 1 level 2]]
1. [[#Title 1 level 3|Title 1 level 3]]
1. [[#Title 2 level 1|Title 2 level 1]]
1. [[#Title 3 level 1|Title 3 level 1]]
1. [[#Title 3 level 2|Title 3 level 2]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list with min level', () => {
const options = parseOptionsFromSourceText('')
options.minLevel = 2
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
- [[#Title 1 level 2|Title 1 level 2]]
- [[#Title 1 level 3|Title 1 level 3]]
- [[#Title 3 level 2|Title 3 level 2]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list with max level', () => {
const options = parseOptionsFromSourceText('')
options.maxLevel = 2
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
- [[#Title 1 level 1|Title 1 level 1]]
- [[#Title 1 level 2|Title 1 level 2]]
- [[#Title 2 level 1|Title 2 level 1]]
- [[#Title 3 level 1|Title 3 level 1]]
- [[#Title 3 level 2|Title 3 level 2]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list without links', () => {
const options = parseOptionsFromSourceText('')
options.includeLinks = false
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
- Title 1 level 1
- Title 1 level 2
- Title 1 level 3
- Title 2 level 1
- Title 3 level 1
- Title 3 level 2
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list with title', () => {
const options = parseOptionsFromSourceText('')
options.title = '# My TOC'
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
# My TOC
- [[#Title 1 level 1|Title 1 level 1]]
- [[#Title 1 level 2|Title 1 level 2]]
- [[#Title 1 level 3|Title 1 level 3]]
- [[#Title 2 level 1|Title 2 level 1]]
- [[#Title 3 level 1|Title 3 level 1]]
- [[#Title 3 level 2|Title 3 level 2]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list with sanitized links from special chars', () => {
const options = parseOptionsFromSourceText('')
const md = getMarkdownFromHeadings(testHeadingsWithSpecialChars, options)
const expectedMd = sanitizeMd(`
- [[#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]]
- [[#Title 1 level 2 <em style="color: black">with HTML</em>|Title 1 level 2 <em style="color: black">with HTML</em>]]
- [[#Title 1 level 2 wikilink1 wikilink2 wikitext2 [mdlink](https://mdurl)|Title 1 level 2 wikilink1 wikitext2 mdlink]]
- [[#Title 1 level 2 malformedlink a pi pe and [other chars]|Title 1 level 2 malformedlink a pi-pe - and [other chars]]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns indented list without links from special chars', () => {
const options = parseOptionsFromSourceText('')
options.includeLinks = false
const md = getMarkdownFromHeadings(testHeadingsWithSpecialChars, options)
const expectedMd = sanitizeMd(`
- Title 1 \`level 1\` {with special chars}, **bold**, _italic_, #a-tag, ==highlighted== and ~~strikethrough~~ text
- Title 1 level 2 <em style="color: black">with HTML</em>
- Title 1 level 2 [[wikilink1]] [[wikilink2|wikitext2]] [mdlink](https://mdurl)
- Title 1 level 2 [[malformedlink a pi|pe | and [other chars]
`)
expect(md).toEqual(expectedMd)
})
test('Returns flat first-level list with links', () => {
const options = parseOptionsFromSourceText('')
options.style = 'inlineFirstLevel'
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
[[#Title 1 level 1|Title 1 level 1]] | [[#Title 2 level 1|Title 2 level 1]] | [[#Title 3 level 1|Title 3 level 1]]
`)
expect(md).toEqual(expectedMd)
})
test('Returns flat first-level list without links', () => {
const options = parseOptionsFromSourceText('')
options.style = 'inlineFirstLevel'
options.includeLinks = false
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
Title 1 level 1 | Title 2 level 1 | Title 3 level 1
`)
expect(md).toEqual(expectedMd)
})
test('Returns flat list with custom level', () => {
const options = parseOptionsFromSourceText('')
options.style = 'inlineFirstLevel'
options.includeLinks = false
options.minLevel = 3
const md = getMarkdownFromHeadings(testStandardHeadings, options)
const expectedMd = sanitizeMd(`
Title 1 level 3
`)
expect(md).toEqual(expectedMd)
})
test('Returns flat list with default level', () => {
const options = parseOptionsFromSourceText('')
options.style = 'inlineFirstLevel'
options.includeLinks = false
const md = getMarkdownFromHeadings(testHeadingsWithoutFirstLevel, options)
const expectedMd = sanitizeMd(`
Title 1 level 2 | Title 2 level 2 | Title 3 level 2
`)
expect(md).toEqual(expectedMd)
})
})
function sanitizeMd(md) {
return md.replaceAll(' ', '\t').replace(/^\n/, '').replace(/\n$/, '')
}

View File

@ -0,0 +1,8 @@
const path = require('path')
module.exports = async () => {
return {
testMatch: [path.join(__dirname, '**/*.test.js')],
testTimeout: 1000,
}
}

View File

@ -0,0 +1,102 @@
const { parseOptionsFromSourceText } = require('../main.js')
describe('Options', () => {
test('Returns default options if none are specified', () => {
const options = parseOptionsFromSourceText('')
expect(options).toEqual({
title: '',
style: 'nestedList',
includeLinks: true,
minLevel: 0,
maxLevel: 0,
hideWhenEmpty: false,
debugInConsole: false,
})
})
test('Returns custom options if specified', () => {
const optionsText = `
title: # Some title
style: inlineFirstLevel # Some comment
minLevel: 1
maxLevel: 2 # Some other comment
includeLinks: false
hideWhenEmpty: true
debugInConsole: true
`
const options = parseOptionsFromSourceText(optionsText)
expect(options).toEqual({
title: '# Some title',
style: 'inlineFirstLevel',
includeLinks: false,
minLevel: 1,
maxLevel: 2,
hideWhenEmpty: true,
debugInConsole: true,
})
})
test('Accepts comments in options', () => {
const options = parseOptionsFromSourceText('maxLevel: 5 # some comment')
expect(options.maxLevel).toEqual(5)
})
test('Ignores unknown options', () => {
const options = parseOptionsFromSourceText(`
maxLevel: 5
someOption: someValue
`)
expect(options.maxLevel).toEqual(5)
})
describe('Throw an error if the option value is invalid', () => {
test('On style', () => {
try {
const options = parseOptionsFromSourceText('style: someInvalidStyle')
expect(options.style).toEqual('Should have thrown')
} catch(error) {
expect(error.message).toContain('Invalid value')
}
})
test('On minLevel', () => {
try {
const options = parseOptionsFromSourceText('minLevel: -1')
expect(options.minLevel).toEqual('Should have thrown')
} catch(error) {
expect(error.message).toContain('Invalid value')
}
})
test('On maxLevel', () => {
try {
const options = parseOptionsFromSourceText('maxLevel: -1')
expect(options.maxLevel).toEqual('Should have thrown')
} catch(error) {
expect(error.message).toContain('Invalid value')
}
})
test('On includeLinks', () => {
try {
const options = parseOptionsFromSourceText('includeLinks: no')
expect(options.includeLinks).toEqual('Should have thrown')
} catch(error) {
expect(error.message).toContain('Invalid value')
}
})
test('On hideWhenEmpty', () => {
try {
const options = parseOptionsFromSourceText('hideWhenEmpty: maybe')
expect(options.hideWhenEmpty).toEqual('Should have thrown')
} catch(error) {
expect(error.message).toContain('Invalid value')
}
})
test('On debugInConsole', () => {
try {
const options = parseOptionsFromSourceText('debugInConsole: yes')
expect(options.debugInConsole).toEqual('Should have thrown')
} catch(error) {
expect(error.message).toContain('Invalid value')
}
})
})
})