insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
195 lines (172 loc) • 7.87 kB
text/typescript
import type { HeadingCache } from "obsidian";
import { describe, expect, test } from "vitest";
import { TocModel } from "../src/tocModel";
import type { FileKey, TocBlockItem, TocBlockModel } from "../src/types";
import { createTocModelPluginMock } from "./mocks/pluginClassMocks";
import {
testHeadingsMixed,
testHeadingsWithSpecialChars,
testHeadingsWithoutFirstLevel,
testOmitHeadingRegex,
testStandardHeadings
} from "./mocks/testingObjects";
type ExpectedTocItem = { text: string; href: string; children: ExpectedTocItem[]; };
function testGetIndentationLevel(
headingLevel: number,
headingLevelStack: number[]
): { currentIndentLevel: number; headingLevelStack: number[]; } {
// Pop from the stack until we find a heading level less than the current
while (
headingLevelStack.length > 0 // Avoid indentation for the first heading
&& headingLevel <= headingLevelStack[headingLevelStack.length - 1]
) {
headingLevelStack.pop();
}
headingLevelStack.push(headingLevel);
const currentIndentLevel = headingLevelStack.length - 1;
return { currentIndentLevel, headingLevelStack };
}
function buildTocSource(headings: HeadingCache[]): string {
let headingLevelStack: number[] = [];
return headings
.map((headingCache) => {
const { currentIndentLevel, headingLevelStack: nextStack } = testGetIndentationLevel(
headingCache.level,
headingLevelStack
);
headingLevelStack = nextStack;
return `${" ".repeat(currentIndentLevel * 4)}- ${headingCache.heading}`;
})
.join("\n");
}
function createModel(source: string, sourceFilePath: FileKey): TocBlockModel {
const { app, settings, uiStateManager } = createTocModelPluginMock();
return new TocModel(
uiStateManager,
app,
settings,
{ localSettings: source, sourceFilePath },
(key) => uiStateManager.getTocFoldState(key)
)
.model;
}
function toExpectedItems(items: TocBlockItem[]): ExpectedTocItem[] {
return items.map((item) => ({ text: item.text, href: item.href, children: toExpectedItems(item.children) }));
}
function buildRenderedItems(headings: HeadingCache[], fileName: string): ExpectedTocItem[] {
const filteredHeadings = headings.filter(({ heading }) => !testOmitHeadingRegex.test(heading));
const source = buildTocSource(filteredHeadings);
const model = createModel(source, `${fileName}.md`);
return toExpectedItems(model.items);
}
describe("TocModel headings", () => {
test("builds nested items from ordered heading levels", () => {
// Arrange & Act
const items = buildRenderedItems(testStandardHeadings, "testStandardHeadings");
// Assert
expect(items).toEqual([ {
text: "Title 1 Level 1",
href: "testStandardHeadings#Title 1 Level 1",
children: [ {
text: "Title 1 Level 2",
href: "testStandardHeadings#Title 1 Level 2",
children: [ {
text: "Title 1 Level 3",
href: "testStandardHeadings#Title 1 Level 3",
children: [ {
text: "Title 1 Level 4",
href: "testStandardHeadings#Title 1 Level 4",
children: [ {
text: "Title 1 Level 5",
href: "testStandardHeadings#Title 1 Level 5",
children: [ {
text: "Title 1 Level 6",
href: "testStandardHeadings#Title 1 Level 6",
children: []
} ]
} ]
} ]
} ]
} ]
} ]);
});
test("builds nested items when the first heading is not level one", () => {
// Arrange & Act
const items = buildRenderedItems(testHeadingsWithoutFirstLevel, "testHeadingsWithoutFirstLevel");
// Assert
expect(items).toEqual([ {
text: "Title 1 Level 2",
href: "testHeadingsWithoutFirstLevel#Title 1 Level 2",
children: [ {
text: "Title 1 Level 3",
href: "testHeadingsWithoutFirstLevel#Title 1 Level 3",
children: [ {
text: "Title 1 Level 4",
href: "testHeadingsWithoutFirstLevel#Title 1 Level 4",
children: [ {
text: "Title 1 Level 5",
href: "testHeadingsWithoutFirstLevel#Title 1 Level 5",
children: [ {
text: "Title 1 Level 6",
href: "testHeadingsWithoutFirstLevel#Title 1 Level 6",
children: []
} ]
} ]
} ]
} ]
} ]);
});
test("builds sibling branches from disorderly heading levels", () => {
// Arrange & Act
const items = buildRenderedItems(testHeadingsMixed, "testHeadingsMixed");
// Assert
expect(items).toEqual([ { text: "Title 1 Level 4", href: "testHeadingsMixed#Title 1 Level 4", children: [] }, {
text: "Title 1 Level 1",
href: "testHeadingsMixed#Title 1 Level 1",
children: [ { text: "Title 1 Level 6", href: "testHeadingsMixed#Title 1 Level 6", children: [] }, {
text: "Title 1 Level 2",
href: "testHeadingsMixed#Title 1 Level 2",
children: []
}, {
text: "Title 2 Level 2",
href: "testHeadingsMixed#Title 2 Level 2",
children: [ { text: "Title 1 Level 3", href: "testHeadingsMixed#Title 1 Level 3", children: [] } ]
} ]
} ]);
});
test("sanitizes rendered link text while preserving transformed href targets", () => {
// Arrange & Act
const items = buildRenderedItems(testHeadingsWithSpecialChars, "testHeadingsWithSpecialChars");
// Assert
expect(items).toEqual([ {
text: "Title 1 level 1 with special chars, bold, italic, a-tag, highlighted and strikethrough text",
href:
"testHeadingsWithSpecialChars#Title 1 `level 1` {with special chars}, **bold**, _italic_, a-tag, ==highlighted== and ~~strikethrough~~ text",
children: [ {
text: "Title 1 level 2 with HTML",
href: "testHeadingsWithSpecialChars#Title 1 level 2 <em style=\"color: black\">with HTML</em>",
children: [ {
text: "Title 1 level 3 wikilink1 wikitext2 mdlink",
href:
"testHeadingsWithSpecialChars#Title 1 level 3 wikilink1 wikilink2 wikitext2 [mdlink](https://mdurl)",
children: [ {
text: "Title 1 level 4 wikilink1 wikitext2 mdlink1 wikilink3 wikitext3 mdlink2",
href:
"testHeadingsWithSpecialChars#Title 1 level 4 wikilink1 wikilink2 wikitext2 [mdlink1](https://mdurl) wikilink3 wikilink4 wikitext3 [mdlink2](https://mdurl)",
children: []
} ]
} ]
} ]
} ]);
});
test("falls back to contentText when a markdown link alias is empty", () => {
// Arrange & Act
const model = createModel("- [](https://example.com)", "empty-markdown-alias.md");
// Assert
expect(toExpectedItems(model.items)).toEqual([ {
text: "[](https://example.com)",
href: "empty-markdown-alias#[](https://example.com)",
children: []
} ]);
});
});