feat: support configurable index.md homepage

Add an index file setting in Obsidian and normalize index paths to source-relative values so homepage, TOC, and sidebar tree consistently resolve and hide the selected index file.
This commit is contained in:
barkstone2 2026-02-08 16:02:02 +09:00 committed by barkstone2
parent fafbce2f27
commit ca68164d62
12 changed files with 280 additions and 20 deletions

View file

@ -9,6 +9,7 @@ import {VTBPublishManager} from "./src/layout/VTBPublishManager";
export interface VaultToBlogSettings {
sourceDir: string;
indexFilePath: string;
repositoryUrl: string;
isActivated: boolean;
version: string;
@ -19,6 +20,7 @@ export interface VaultToBlogSettings {
const DEFAULT_SETTINGS: VaultToBlogSettings = {
sourceDir: '',
indexFilePath: '',
repositoryUrl: '',
isActivated: false,
version: '0.0.1',
@ -117,4 +119,4 @@ export default class VaultToBlog extends Plugin {
async openPublishManager() {
new VTBPublishManager(this.app, this.gitUtils, this.paths, this.fileUtils).open()
}
}
}

View file

@ -0,0 +1,39 @@
import {beforeEach, describe, expect, it, vi} from "vitest";
import {render, waitFor} from "@testing-library/react";
import {MemoryRouter, Route, Routes} from "react-router-dom";
import {HelmetProvider} from "react-helmet-async";
vi.mock('../stores/data.json', () => ({
default: {
indexFilePath: 'index.md',
isEnableComments: false,
repo: '',
theme: '',
}
}));
import MarkdownContent from "./MarkdownContent.jsx";
describe('index file path 설정 시', () => {
beforeEach(() => {
global.fetch = vi.fn(() => Promise.resolve({
text: () => Promise.resolve('<div>index</div>')
}));
});
it('루트 경로 요청은 index.md html을 반환한다.', async () => {
render(
<MemoryRouter initialEntries={['/']}>
<HelmetProvider>
<Routes>
<Route path="*" element={<MarkdownContent/>}/>
</Routes>
</HelmetProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/html/index.html');
});
});
});

View file

@ -5,18 +5,32 @@ import LoadingScreen from "./LoadingScreen.jsx";
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import UtterancesComments from "./UtterancesComments.jsx";
import {getIndexFilePath} from "../utils/file/fileUtils.js";
export function resolveMarkdownFilePath(filePath, indexFilePath) {
if (filePath && filePath !== '') {
return filePath;
}
return indexFilePath || '';
}
function resolveTitle(filePath) {
return filePath ? filePath.split('/').pop().replace('.md', '') : 'Home';
}
function MarkdownContent() {
const {'*': filePath} = useParams();
const indexFilePath = getIndexFilePath();
const [innerHtml, setInnerHtml] = useState({});
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
setIsLoading(true);
const fetchHtml = async () => {
const path = filePath ? `/html/${filePath.replace('.md', '.html')}` : `/default/home.html`;
const resolvedFilePath = resolveMarkdownFilePath(filePath, indexFilePath);
const path = resolvedFilePath ? `/html/${resolvedFilePath.replace('.md', '.html')}` : `/default/home.html`;
const response = await fetch(`${path}`);
const content = await response.text();
const title = filePath.split('/').pop().replace('.md', '');
const title = resolveTitle(resolvedFilePath);
const newInnerHtml = {
title: title,
content: content
@ -25,7 +39,7 @@ function MarkdownContent() {
setIsLoading(false);
};
fetchHtml()
}, [filePath]);
}, [filePath, indexFilePath]);
useBacklinkNavigation(innerHtml.content);
if (isLoading) {
@ -40,9 +54,9 @@ function MarkdownContent() {
<div className="markdown-preview-sizer markdown-preview-section"
dangerouslySetInnerHTML={{__html: innerHtml.content}}>
</div>
{ filePath !== '' && <UtterancesComments /> }
{ resolveMarkdownFilePath(filePath, indexFilePath) !== '' && <UtterancesComments /> }
</>
);
}
export default MarkdownContent;
export default MarkdownContent;

View file

@ -0,0 +1,14 @@
import {describe, expect, it} from "vitest";
import {resolveEffectiveMarkdownPath, resolveTocHeaders} from "./WorkspaceContainer";
describe('WorkspaceContainer helpers', () => {
it('홈 경로이고 index 파일이 있으면 index 파일 경로를 사용한다.', () => {
const resolved = resolveEffectiveMarkdownPath('', 'index.md');
expect(resolved).toBe('index.md');
});
it('toc 맵에 키가 없으면 빈 배열을 반환한다.', () => {
const headers = resolveTocHeaders({}, '', 'index.md');
expect(headers).toStrictEqual([]);
});
});

View file

@ -7,20 +7,37 @@ import FileExplorerTabHeader from "../components/sidebar/FileExplorerTabHeader";
import {WorkspaceTabHeader} from "../components/tab-header/WorkspaceTabHeader";
import {LucideList} from "lucide-react";
import {useParams} from "react-router-dom";
import {getTocMap} from "../utils/file/fileUtils";
import {getIndexFilePath, getTocMap} from "../utils/file/fileUtils";
import {TocHeaders} from "../components/tab-header/TocHeaders";
import {SidebarRightToggleSvg} from "../components/svg/SidebarRightToggleSvg";
import {SidebarToggleButton} from "../components/SidebarToggleButton";
import {SidebarLeftToggleSvg} from "../components/svg/SidebarLeftToggleSvg";
import {HeaderOfToc} from "../types/HeaderOfToc";
export function resolveEffectiveMarkdownPath(filePath: string | undefined, indexFilePath: string): string {
if (filePath && filePath !== '') {
return filePath;
}
return indexFilePath || '';
}
export function resolveTocHeaders(
tocMap: {[key: string]: HeaderOfToc},
filePath: string | undefined,
indexFilePath: string,
): HeaderOfToc[] {
const resolvedPath = resolveEffectiveMarkdownPath(filePath, indexFilePath);
const tocOfFile = tocMap[resolvedPath];
return tocOfFile?.children ?? [];
}
const leftSideDockOpenedClassName = 'is-left-sidedock-open';
const rightSideDockOpenedClassName = 'is-right-sidedock-open';
export function WorkspaceContainer({children}: {children: ReactNode}) {
const {'*': filePath } = useParams();
const tocMap: {[key: string]: HeaderOfToc} = getTocMap();
const tocOfFile: HeaderOfToc = tocMap[filePath ?? ''];
const tocMap = getTocMap() as {[key: string]: HeaderOfToc};
const tocHeaders = resolveTocHeaders(tocMap, filePath, getIndexFilePath());
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
@ -152,7 +169,7 @@ export function WorkspaceContainer({children}: {children: ReactNode}) {
<div className="workspace-leaf-content" data-type={'outline'}>
<div className="view-content node-insert-event" style={{position: 'relative'}}>
<TocHeaders headers={tocOfFile.children}/>
<TocHeaders headers={tocHeaders}/>
</div>
</div>
</div>
@ -161,4 +178,4 @@ export function WorkspaceContainer({children}: {children: ReactNode}) {
</WorkspaceSplitContainer>
</div>
)
}
}

View file

@ -1 +1,10 @@
{}
{
"sourceDir": "",
"indexFilePath": "",
"repositoryUrl": "",
"isActivated": false,
"version": "",
"isEnableComments": false,
"repo": "",
"theme": ""
}

View file

@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import mime from "mime";
import data from "../../stores/data.json";
const sourceDir = 'public/sources';
const imageJsonFilePath = 'image-files.json';
@ -97,6 +98,29 @@ export function getMarkdownFileMap() {
return markdownFileMap;
}
export function normalizeIndexFilePath(indexFilePath, sourceDir) {
if (!indexFilePath) {
return '';
}
const normalizedIndexPath = indexFilePath.normalize('NFC');
if (!sourceDir) {
return normalizedIndexPath;
}
const normalizedSourceDir = sourceDir.normalize('NFC').replace(/^\/+|\/+$/g, '');
const sourcePrefix = `${normalizedSourceDir}/`;
if (normalizedIndexPath.startsWith(sourcePrefix)) {
return normalizedIndexPath.substring(sourcePrefix.length);
}
return normalizedIndexPath;
}
export function getIndexFilePath() {
return normalizeIndexFilePath(data.indexFilePath || '', data.sourceDir || '');
}
let markdownFileSet = null;
export function getMarkdownFileSet() {
if (!markdownFileSet) {
@ -141,4 +165,4 @@ export function initSourceDirectory() {
if (!fs.existsSync(sourceDir)) {
fs.mkdirSync(sourceDir, { recursive: true });
}
}
}

View file

@ -3,9 +3,11 @@ import {
createFileMapToJson,
createImageMapToJson,
getImageFileMap,
getIndexFilePath,
getMarkdownFileMap,
initImageFileMap,
initMarkdownFileMap,
normalizeIndexFilePath,
} from "./fileUtils.js";
import fs from "fs";
@ -310,4 +312,20 @@ describe('파일 셋 조회 요청 시', () => {
expect(cachedSet).not.toStrictEqual(expectedFileSet);
expect(cachedSet).toStrictEqual(initSet);
});
});
});
describe('index 파일 경로 정규화 요청 시', () => {
it('sourceDir를 포함한 index 경로를 source 기준 상대 경로로 정규화한다.', () => {
const normalized = normalizeIndexFilePath('!new-blog/index.md', '!new-blog');
expect(normalized).toBe('index.md');
});
it('이미 source 기준 상대 경로면 그대로 반환한다.', () => {
const normalized = normalizeIndexFilePath('index.md', '!new-blog');
expect(normalized).toBe('index.md');
});
it('설정 데이터에 index 경로가 없으면 빈 값을 반환한다.', () => {
expect(getIndexFilePath()).toBe('');
});
});

View file

@ -1,5 +1,5 @@
import React from 'react'
import {getMarkdownFileSet} from "./file/fileUtils.js";
import {getIndexFilePath, getMarkdownFileSet} from "./file/fileUtils.js";
import TreeItem from "../components/TreeItem.jsx";
const calculateCounts = (node) => {
@ -39,8 +39,12 @@ const buildTree = (paths) => {
return tree;
};
export const initTree = () => {
export const initTree = (indexMarkdownPath = getIndexFilePath()) => {
const fileSet = getMarkdownFileSet();
const indexFilePath = indexMarkdownPath.normalize('NFC');
if (indexFilePath) {
fileSet.delete(indexFilePath);
}
return buildTree(fileSet)
}
@ -84,4 +88,4 @@ export const markUsedPaths = (path = location.pathname) => {
return usedPaths
}
return usedPaths
}
}

View file

@ -8,7 +8,8 @@ let expectedTree;
beforeAll(() => {
vi.mock('./file/fileUtils.js', () => {
return {
getMarkdownFileSet: () => fileSet
getMarkdownFileSet: () => fileSet,
getIndexFilePath: () => ''
}
})
vi.mock('../components/TreeItem', () => ({
@ -100,6 +101,26 @@ describe('initTree 호출 시', () => {
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('index 파일 경로가 전달되면 해당 파일은 트리에서 제외한다.', () => {
fileSet = new Set(['index.md', 'posts/post-1.md']);
expectedTree = {
children: {
posts: {
children: {
'post-1.md': {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false,
},
},
count: 1,
};
const tree = initTree('index.md');
expect(tree.count).toBe(1);
expect(tree).toStrictEqual(expectedTree);
});
});
describe('renderTree 호출 시', () => {
@ -197,4 +218,4 @@ describe('markUsedPaths 호출 시', () => {
const result = markUsedPaths(path);
expect(result).toStrictEqual(expectedObject)
});
});
});

View file

@ -1,9 +1,10 @@
import {App, normalizePath, Notice, PluginSettingTab, Setting, TFolder} from "obsidian";
import {App, normalizePath, Notice, PluginSettingTab, Setting, TFile, TFolder} from "obsidian";
import VTBPlugin, {VaultToBlogSettings} from "../../main";
import {Paths} from "../store/paths";
import {GitUtils} from "../utils/gitUtils";
import {FileUtils} from "../utils/fileUtils";
import {FolderSuggester} from "../suggester/FolderSuggester";
import {MarkdownFileSuggester} from "../suggester/MarkdownFileSuggester";
export class VTBSettingTab extends PluginSettingTab {
plugin: VTBPlugin;
@ -28,6 +29,7 @@ export class VTBSettingTab extends PluginSettingTab {
const propertiesContainer = containerEl.createDiv()
this.createSourceDirSetting(propertiesContainer)
this.createIndexFileSetting(propertiesContainer)
this.createRepositoryUrlSetting(propertiesContainer);
containerEl.append(propertiesContainer)
@ -69,6 +71,9 @@ export class VTBSettingTab extends PluginSettingTab {
}
this.settings.sourceDir = inputEl?.value;
if (this.settings.indexFilePath && !this.isValidIndexFile(this.settings.indexFilePath)) {
this.settings.indexFilePath = '';
}
await this.deactivate();
this.display()
new Notice('Setting saved.');
@ -80,6 +85,73 @@ export class VTBSettingTab extends PluginSettingTab {
return directories.includes(sourceDir);
}
private createIndexFileSetting(containerEl: HTMLElement) {
let inputEl: HTMLInputElement;
let isSaveDisabled = false;
const desc = new DocumentFragment();
desc.createDiv({text: 'Select an index.md file to use as homepage.'});
desc.createDiv({text: 'This file must be under Source Dir and will be hidden from the left sidebar.', cls: 'vtb-warning'});
if (!this.settings.sourceDir) {
desc.createDiv({text: 'Select Source Dir first.', cls: 'vtb-warning'});
isSaveDisabled = true;
}
const setting = new Setting(containerEl)
.setName('Index File')
.setDesc(desc)
.setTooltip('Select an index markdown file under Source Dir. Leave empty to use default home.html.')
.addSearch((cb) => {
inputEl = cb.inputEl;
new MarkdownFileSuggester(this.app, inputEl, this.settings.sourceDir);
cb.setPlaceholder('Enter an index markdown file path');
cb.setValue(this.settings.indexFilePath);
cb.setDisabled(isSaveDisabled);
})
.addButton((cb) => {
cb.setButtonText('Save');
cb.setDisabled(isSaveDisabled);
cb.onClick(() => this.saveIndexFileSetting(inputEl));
});
this.addDefaultSettingClass(setting)
containerEl.createDiv({cls: 'vtb-current-value', text: 'Index File : ' + (this.settings.indexFilePath || '(default home.html)')});
}
private async saveIndexFileSetting(inputEl: HTMLInputElement) {
if (!this.settings.sourceDir) {
new Notice('Source Dir must be selected first.', 3000)
return;
}
const selectedPath = normalizePath(inputEl?.value ?? '');
if (selectedPath !== '' && !this.isValidIndexFile(selectedPath)) {
new Notice('Invalid index markdown file path.', 3000)
inputEl.value = this.settings.indexFilePath;
return;
}
this.settings.indexFilePath = selectedPath;
await this.deactivate();
this.display();
new Notice('Setting saved.');
}
private isValidIndexFile(indexFilePath: string) {
if (!indexFilePath.endsWith('.md')) {
return false;
}
const markdownFiles = this.app.vault.getMarkdownFiles()
.map((it: TFile) => normalizePath(it.path));
if (!markdownFiles.includes(indexFilePath)) {
return false;
}
if (!this.settings.sourceDir) {
return false;
}
return indexFilePath.startsWith(`${this.settings.sourceDir}/`);
}
private addDefaultSettingClass(setting: Setting) {
setting.settingEl.addClass('vtb-setting')
setting.controlEl.addClass('vtb-control')

View file

@ -0,0 +1,26 @@
import {AbstractInputSuggest, App, normalizePath, TFile} from "obsidian";
export class MarkdownFileSuggester extends AbstractInputSuggest<string> {
private readonly sourceDir: string;
constructor(app: App, inputEl: HTMLInputElement, sourceDir: string) {
super(app, inputEl);
this.sourceDir = sourceDir;
}
getSuggestions(query: string): string[] | Promise<string[]> {
return this.app.vault.getMarkdownFiles()
.map((it: TFile) => normalizePath(it.path))
.filter((it) => it.startsWith(`${this.sourceDir}/`))
.filter((it) => it.contains(query));
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
selectSuggestion(value: string): void {
this.setValue(value);
this.close();
}
}