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(); + } +}