insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
137 lines (111 loc) • 4.57 kB
text/typescript
import { parseYaml as parseYml } from "obsidian";
import { deepMerge } from "../Utils";
import type { HeadingLevel, LocalTocSettings, ParseLocalTocSettingsResult, TocBlockTitle } from "../types";
import { getDefaultLocalSettings, type InstaTocSettings } from "./Settings";
type PartialLocalTocSettings = Partial<LocalTocSettings> & Record<string, unknown>;
function isHeadingLevel(value: any): value is HeadingLevel {
return [ 1, 2, 3, 4, 5, 6 ].includes(value);
}
function sanitizeYaml(value: string): string {
return value
.replace(/(\w+):(\S)/g, "$1: $2") // Add space after colon: "min:1" → "min: 1"
.replace(
/(?<indent>^[ \t]*)(?<key>[A-Za-z_][A-Za-z0-9_-]*[ \t]*:)[ \t]*(?:(?<value>[^:\n#]+)[ \t]+)?(?<nextKeyWithValue>[A-Za-z_][A-Za-z0-9_-]*[ \t]*:(?:[ \t]*(?:#.*)?|[ \t]+[^#\n]*(?:#.*)?))$/gm,
"$<indent>$<key> $<value>\n$<indent>$<nextKeyWithValue>"
); // Newline before each key: "min: 1 max: 6" → "min: 1\nmax: 6"
}
function parseYaml<T>(yaml: string): T {
return parseYml(yaml) as T;
}
export function normalizeLocalTocSettings(settings: Partial<LocalTocSettings> | null | undefined): LocalTocSettings {
return {
title: {
name: settings?.title?.name ?? null,
level: settings?.title?.level ?? null,
center: settings?.title?.center ?? null
},
exclude: settings?.exclude ?? null,
omit: settings?.omit == null ? null : [ ...settings.omit ],
levels: { min: settings?.levels?.min ?? null, max: settings?.levels?.max ?? null }
};
}
export function parseLocalTocSettingsYaml(yaml: string): ParseLocalTocSettingsResult {
const defaults = getDefaultLocalSettings();
if (yaml.trim().length === 0) {
return { settings: defaults, errors: [] };
}
let parsed: PartialLocalTocSettings;
try {
parsed = parseYaml(sanitizeYaml(yaml));
}
catch (error) {
return { settings: defaults, errors: [ `Invalid YAML in insta-toc settings: ${String(error)}` ] };
}
const errors: string[] = [];
if (parsed.title != null) {
if (typeof parsed.title !== "object") {
errors.push("'title' must be an object.");
}
else {
const { name, level, center } = parsed.title;
if (name != null && typeof name !== "string") {
parsed.title.name = String(name);
}
if (level != null && !isHeadingLevel(level)) {
errors.push("'title.level' must be an integer between 1 and 6.");
}
if (center != null && typeof center !== "boolean") {
errors.push("'title.center' must be a boolean.");
}
}
}
if (parsed.exclude != null && typeof parsed.exclude !== "string") {
parsed.exclude = String(parsed.exclude);
}
if (parsed.omit != null) {
if (!Array.isArray(parsed.omit)) {
errors.push("'omit' must be an array of strings.");
}
else {
parsed.omit = parsed.omit.map((item) => item == null ? "" : String(item));
}
}
if (parsed.levels != null) {
if (typeof parsed.levels !== "object") {
errors.push("'levels' must be an object.");
}
else {
const { min, max } = parsed.levels;
if (min != null && !isHeadingLevel(min)) {
errors.push("'levels.min' must be an integer between 1 and 6.");
}
if (max != null && !isHeadingLevel(max)) {
errors.push("'levels.max' must be an integer between 1 and 6.");
}
if (min != null && max != null && min > max) {
errors.push("'levels.min' cannot be greater than 'levels.max'.");
}
}
}
if (errors.length > 0) {
return { settings: defaults, errors };
}
return {
settings: normalizeLocalTocSettings(deepMerge<LocalTocSettings>(defaults, parsed, { dedupArrays: true })),
errors: []
};
}
export function resolveTocTitle(
localTocSettings: Pick<LocalTocSettings, "title">,
settings: Pick<InstaTocSettings, "tocTitle" | "tocTitleLevel" | "tocTitleCentered">
): TocBlockTitle | null {
const text = localTocSettings.title.name ?? settings.tocTitle;
if (text.trim().length === 0) {
return null;
}
return {
text,
level: localTocSettings.title.level ?? settings.tocTitleLevel,
centerOverride: localTocSettings.title.center
};
}