mirror of
https://github.com/barkstone2/vault-to-blog.git
synced 2026-07-22 05:21:19 +00:00
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:
parent
fafbce2f27
commit
ca68164d62
12 changed files with 280 additions and 20 deletions
4
main.ts
4
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,10 @@
|
|||
{}
|
||||
{
|
||||
"sourceDir": "",
|
||||
"indexFilePath": "",
|
||||
"repositoryUrl": "",
|
||||
"isActivated": false,
|
||||
"version": "",
|
||||
"isEnableComments": false,
|
||||
"repo": "",
|
||||
"theme": ""
|
||||
}
|
||||
|
|
|
|||
26
react-app-internal/src/utils/file/fileUtils.js
vendored
26
react-app-internal/src/utils/file/fileUtils.js
vendored
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
26
src/suggester/MarkdownFileSuggester.ts
Normal file
26
src/suggester/MarkdownFileSuggester.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue