diff --git a/main.ts b/main.ts
index aeb1b4a..310a633 100644
--- a/main.ts
+++ b/main.ts
@@ -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()
}
-}
\ No newline at end of file
+}
diff --git a/react-app-internal/src/components/MarkdownContent.index.test.jsx b/react-app-internal/src/components/MarkdownContent.index.test.jsx
new file mode 100644
index 0000000..106fc69
--- /dev/null
+++ b/react-app-internal/src/components/MarkdownContent.index.test.jsx
@@ -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('
index
')
+ }));
+ });
+
+ it('루트 경로 요청은 index.md html을 반환한다.', async () => {
+ render(
+
+
+
+ }/>
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith('/html/index.html');
+ });
+ });
+});
diff --git a/react-app-internal/src/components/MarkdownContent.jsx b/react-app-internal/src/components/MarkdownContent.jsx
index 6597eae..e3fca3b 100644
--- a/react-app-internal/src/components/MarkdownContent.jsx
+++ b/react-app-internal/src/components/MarkdownContent.jsx
@@ -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() {
- { filePath !== '' && }
+ { resolveMarkdownFilePath(filePath, indexFilePath) !== '' && }
>
);
}
-export default MarkdownContent;
\ No newline at end of file
+export default MarkdownContent;
diff --git a/react-app-internal/src/containers/WorkspaceContainer.test.jsx b/react-app-internal/src/containers/WorkspaceContainer.test.jsx
new file mode 100644
index 0000000..9296a1a
--- /dev/null
+++ b/react-app-internal/src/containers/WorkspaceContainer.test.jsx
@@ -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([]);
+ });
+});
diff --git a/react-app-internal/src/containers/WorkspaceContainer.tsx b/react-app-internal/src/containers/WorkspaceContainer.tsx
index 7d987af..c6b67d4 100644
--- a/react-app-internal/src/containers/WorkspaceContainer.tsx
+++ b/react-app-internal/src/containers/WorkspaceContainer.tsx
@@ -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}) {
@@ -161,4 +178,4 @@ export function WorkspaceContainer({children}: {children: ReactNode}) {
)
-}
\ No newline at end of file
+}
diff --git a/react-app-internal/src/stores/data.json b/react-app-internal/src/stores/data.json
index 9e26dfe..d934562 100644
--- a/react-app-internal/src/stores/data.json
+++ b/react-app-internal/src/stores/data.json
@@ -1 +1,10 @@
-{}
\ No newline at end of file
+{
+ "sourceDir": "",
+ "indexFilePath": "",
+ "repositoryUrl": "",
+ "isActivated": false,
+ "version": "",
+ "isEnableComments": false,
+ "repo": "",
+ "theme": ""
+}
diff --git a/react-app-internal/src/utils/file/fileUtils.js b/react-app-internal/src/utils/file/fileUtils.js
index 5cec6fa..9b89452 100644
--- a/react-app-internal/src/utils/file/fileUtils.js
+++ b/react-app-internal/src/utils/file/fileUtils.js
@@ -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 });
}
-}
\ No newline at end of file
+}
diff --git a/react-app-internal/src/utils/file/fileUtils.test.js b/react-app-internal/src/utils/file/fileUtils.test.js
index 5279919..b3c1c68 100644
--- a/react-app-internal/src/utils/file/fileUtils.test.js
+++ b/react-app-internal/src/utils/file/fileUtils.test.js
@@ -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);
});
-});
\ No newline at end of file
+});
+
+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('');
+ });
+});
diff --git a/react-app-internal/src/utils/treeUtils.jsx b/react-app-internal/src/utils/treeUtils.jsx
index 5be8f9b..d8e974b 100644
--- a/react-app-internal/src/utils/treeUtils.jsx
+++ b/react-app-internal/src/utils/treeUtils.jsx
@@ -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
-}
\ No newline at end of file
+}
diff --git a/react-app-internal/src/utils/treeUtils.test.jsx b/react-app-internal/src/utils/treeUtils.test.jsx
index 376b2c6..80f3162 100644
--- a/react-app-internal/src/utils/treeUtils.test.jsx
+++ b/react-app-internal/src/utils/treeUtils.test.jsx
@@ -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)
});
-});
\ No newline at end of file
+});
diff --git a/src/layout/settingTab.ts b/src/layout/settingTab.ts
index 367281b..f9ad500 100644
--- a/src/layout/settingTab.ts
+++ b/src/layout/settingTab.ts
@@ -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')
diff --git a/src/suggester/MarkdownFileSuggester.ts b/src/suggester/MarkdownFileSuggester.ts
new file mode 100644
index 0000000..ae54dd9
--- /dev/null
+++ b/src/suggester/MarkdownFileSuggester.ts
@@ -0,0 +1,26 @@
+import {AbstractInputSuggest, App, normalizePath, TFile} from "obsidian";
+
+export class MarkdownFileSuggester extends AbstractInputSuggest {
+ private readonly sourceDir: string;
+
+ constructor(app: App, inputEl: HTMLInputElement, sourceDir: string) {
+ super(app, inputEl);
+ this.sourceDir = sourceDir;
+ }
+
+ getSuggestions(query: string): string[] | Promise {
+ 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();
+ }
+}