insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
181 lines (152 loc) • 6.81 kB
text/typescript
import { TFile, htmlToMarkdown, type App } from "obsidian";
import {
listRegex,
localTocSettingsRegex,
markdownLinkRegex,
tagLinkRegex,
wikiLinkNoAliasRegex,
wikiLinkWithAliasRegex
} from "./constants";
import type { InstaTocSettings } from "./settings/Settings";
import { parseLocalTocSettingsYaml, resolveTocTitle } from "./settings/localTocSettings";
import type { FileKey, FoldKey, ParseLocalTocSettingsResult, TocBlockItem, TocBlockModel } from "./types";
import type UiStateManager from "./uiStateManager";
type SourceData = { sourceFilePath: FileKey; localSettings: string; };
type FoldState = { foldParentIndex: number; activeFoldKeys: Set<FoldKey>; };
type HandledLink = { contentText: string; alias: string; };
type LinkTransform = {
pattern: RegExp;
/** How to transform contentText (the full heading text for display). null = skip. */
forContent: ((...args: string[]) => string) | null;
/** How to transform alias (the cleaned link text). null = skip. */
forAlias: ((...args: string[]) => string) | null;
};
const linkTransforms: LinkTransform[] = [ {
pattern: wikiLinkWithAliasRegex,
// [[wikilink|wikitext]] → "wikilink wikitext"
forContent: (_match, refPath, refAlias) => `${refPath} ${refAlias}`,
// [[wikilink|wikitext]] → "wikitext"
forAlias: (_match, _refPath, refAlias) => `${refAlias}`
}, {
pattern: wikiLinkNoAliasRegex,
// [[path/to/note]] → "note" (last path segment — same for both)
forContent: (_match, refPath) => String(refPath).split("/").pop() ?? String(refPath),
forAlias: (_match, refPath) => String(refPath).split("/").pop() ?? String(refPath)
}, {
pattern: markdownLinkRegex,
// [Link](url) → keep full match in contentText
forContent: (match) => match,
// [Link](url) → keep just "Link" in alias
forAlias: (_match, refAlias) => String(refAlias)
}, {
pattern: tagLinkRegex,
// #some-tag → "some-tag" (content only — alias doesn't need this)
forContent: (_match, _symbol, tag) => String(tag),
forAlias: null
} ];
function handleLinks(settings: InstaTocSettings, content: string): HandledLink {
let contentText = content;
let alias = content;
for (const { pattern, forContent, forAlias } of linkTransforms) {
if (forContent) contentText = contentText.replace(pattern, forContent);
if (forAlias) alias = alias.replace(pattern, forAlias);
}
// Post-processing for alias: HTML → markdown + excluded chars
alias = htmlToMarkdown(alias);
for (const char of settings.excludedChars) {
alias = alias.replaceAll(char, "");
}
return { contentText, alias };
}
export class TocModel {
private uiStateManager: UiStateManager;
private app: App;
private settings: InstaTocSettings;
private sourceData: SourceData;
private foldStateCallback: (key: FoldKey) => boolean | undefined;
private foldState: FoldState = { foldParentIndex: 0, activeFoldKeys: new Set<FoldKey>() };
private _model: TocBlockModel = { title: null, items: [] };
constructor(
uiStateManager: UiStateManager,
app: App,
settings: InstaTocSettings,
sourceData: SourceData,
foldStateCallback: (key: FoldKey) => boolean | undefined
) {
this.uiStateManager = uiStateManager;
this.app = app;
this.settings = settings;
this.sourceData = sourceData;
this.foldStateCallback = foldStateCallback;
this.generateModel();
}
public get model(): TocBlockModel {
return this._model;
}
private generateModel(): void {
const fileLinkText = this.getLinkText();
const localTocSettings = this.getLocalTocSettingsFromSource().settings;
const resolvedTitle = resolveTocTitle(localTocSettings, this.settings);
const lines = this.sourceData.localSettings.replace(localTocSettingsRegex, "").split("\n");
const stack: Array<{ indent: number; item: TocBlockItem; }> = [];
let itemIndex = 0;
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.length === 0) {
continue;
}
if (!this._model.title) {
this._model.title = resolvedTitle;
}
const match = line.match(listRegex);
if (!match) {
continue;
}
const [ , indent, _bullet, content ] = match;
const { contentText, alias } = handleLinks(this.settings, content);
const normalizedIndent = indent.replace(/\t/g, " ").length;
const item: TocBlockItem = {
key: `${this.sourceData.sourceFilePath}:${itemIndex}`,
text: alias || contentText,
href: `${fileLinkText}#${contentText}`,
children: [],
foldKey: null,
initialCollapsed: false
};
itemIndex += 1;
while (stack.length > 0 && stack[stack.length - 1].indent >= normalizedIndent) {
stack.pop();
}
const siblings = stack.length > 0 ? stack[stack.length - 1].item.children : this._model.items;
siblings.push(item);
stack.push({ indent: normalizedIndent, item });
}
this.assignInitialFoldState();
this.uiStateManager.pruneTocFoldStateForPath(this.sourceData.sourceFilePath, {
activeModernFoldKeys: this.foldState.activeFoldKeys
});
}
public getLinkText(): string {
const file = this.app.vault.getAbstractFileByPath(this.sourceData.sourceFilePath);
const fallbackLinkText = this.sourceData.sourceFilePath.replace(/\.md$/u, "");
const fileLinkText = file instanceof TFile
? this.app.metadataCache.fileToLinktext(file, this.sourceData.sourceFilePath, true)
: fallbackLinkText;
return fileLinkText;
}
public getLocalTocSettingsFromSource(): ParseLocalTocSettingsResult {
const match = this.sourceData.localSettings.match(localTocSettingsRegex);
return parseLocalTocSettingsYaml(match?.[1] ?? "");
}
public assignInitialFoldState(items: TocBlockItem[] = this._model.items): void {
for (const item of items) {
if (item.children.length === 0) continue;
this.foldState.foldParentIndex += 1;
const foldKey: FoldKey = `${this.sourceData.sourceFilePath}::${this.foldState.foldParentIndex}`;
this.foldState.activeFoldKeys.add(foldKey);
item.foldKey = foldKey;
item.initialCollapsed = this.foldStateCallback(foldKey) ?? false;
this.assignInitialFoldState(item.children);
}
}
}