fix: restore sidebar body-content search behavior

This commit is contained in:
barkstone2 2026-02-11 20:33:34 +09:00 committed by barkstone2
parent 9bce010960
commit d62ea9d69d
10 changed files with 1175 additions and 947 deletions

View file

@ -0,0 +1 @@
{}

View file

@ -1,41 +1,42 @@
import './App.css'
import {initImageFileMap, initMarkdownFileMap, initTocMap} from "./utils/file/fileUtils.js";
import {useEffect, useState} from "react";
import MarkdownContent from "./components/MarkdownContent.jsx";
import {Route, Routes} from "react-router-dom";
import LoadingScreen from "./components/LoadingScreen.jsx";
import {WorkspaceContainer} from "./containers/WorkspaceContainer";
function App() {
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
async function initialize() {
import {initImageFileMap, initMarkdownFileMap, initMarkdownSearchMap, initTocMap} from "./utils/file/fileUtils.js";
import {useEffect, useState} from "react";
import MarkdownContent from "./components/MarkdownContent.jsx";
import {Route, Routes} from "react-router-dom";
import LoadingScreen from "./components/LoadingScreen.jsx";
import {WorkspaceContainer} from "./containers/WorkspaceContainer";
function App() {
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
async function initialize() {
await initMarkdownFileMap();
await initMarkdownSearchMap();
await initImageFileMap();
await initTocMap();
setIsInitialized(true);
}
initialize();
}, []);
if (!isInitialized) {
return (<LoadingScreen dataTestId="init-loading-screen"/>);
}
return (
<div className="horizontal-main-container">
<Routes>
<Route path={"/*"}
element={
<WorkspaceContainer>
<MarkdownContent/>
</WorkspaceContainer>
}
>
</Route>
</Routes>
</div>
);
}
export default App;
setIsInitialized(true);
}
initialize();
}, []);
if (!isInitialized) {
return (<LoadingScreen dataTestId="init-loading-screen"/>);
}
return (
<div className="horizontal-main-container">
<Routes>
<Route path={"/*"}
element={
<WorkspaceContainer>
<MarkdownContent/>
</WorkspaceContainer>
}
>
</Route>
</Routes>
</div>
);
}
export default App;

View file

@ -1,44 +1,61 @@
import {render, screen, waitFor} from '@testing-library/react';
import {expect, vi} from 'vitest';
import App from './App';
import {initImageFileMap, initMarkdownFileMap, initTocMap} from './utils/file/fileUtils';
import {initImageFileMap, initMarkdownFileMap, initMarkdownSearchMap, initTocMap} from './utils/file/fileUtils';
import {MemoryRouter} from "react-router-dom";
beforeAll(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({
text: async () => '',
})));
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
vi.mock('./utils/file/fileUtils', () => ({
initMarkdownFileMap: vi.fn(),
initMarkdownSearchMap: vi.fn(),
initImageFileMap: vi.fn(),
initTocMap: vi.fn(),
getTocMap: vi.fn(() => ({})),
getIndexFilePath: vi.fn(() => ''),
getMarkdownFileSet: vi.fn(() => new Set()),
}));
})
})
afterAll(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
})
describe('App 컴포넌트 렌더링 시', () => {
describe('App 컴포넌트 렌더링 시', () => {
it('초기화 메서드가 호출된다.', async () => {
render(<MemoryRouter initialEntries={['file.md']}><App /></MemoryRouter>);
render(<MemoryRouter initialEntries={['/file.md']}><App /></MemoryRouter>);
await waitFor(() => {
expect(initMarkdownFileMap).toHaveBeenCalled();
expect(initMarkdownSearchMap).toHaveBeenCalled();
expect(initImageFileMap).toHaveBeenCalled();
expect(initTocMap).toHaveBeenCalled();
});
});
it('아직 초기화 되지 않았을 때는 로딩 컴포넌트가 렌더링된다.', () => {
render(<MemoryRouter initialEntries={['file.md']}><App /></MemoryRouter>);
expect(screen.getByTestId('init-loading-screen')).toBeInTheDocument();
});
render(<MemoryRouter initialEntries={['/file.md']}><App /></MemoryRouter>);
expect(screen.getByTestId('init-loading-screen')).toBeInTheDocument();
});
it('초기화가 완료되면 App이 렌더링 된다.', async () => {
render(<MemoryRouter initialEntries={['file.md']}><App /></MemoryRouter>);
await waitFor(() => {
expect(screen.queryByTestId('init-loading-screen')).not.toBeInTheDocument();
});
});
});
render(<MemoryRouter initialEntries={['/file.md']}><App /></MemoryRouter>);
await waitFor(() => {
expect(screen.queryByTestId('init-loading-screen')).not.toBeInTheDocument();
});
});
});

View file

@ -1,55 +1,56 @@
import {createContext, useMemo, useRef} from 'react';
import {initTree, markUsedPaths, renderTree} from "../utils/treeUtils.jsx";
export const TreeContainerContext = createContext({});
const directoryFirstFn = ([key1, value1], [key2, value2]) => {
if (value1.isFile !== value2.isFile) {
return value1.isFile ? 1 : -1;
}
return key1.localeCompare(key2);
};
const TreeContainer = ({
datatype,
searchKeyword = '',
forceExpandDirectories = false,
showSearchInput = false,
onSearchKeywordChange = (_nextKeyword) => {},
}) => {
const tree = useMemo(() => initTree(undefined, searchKeyword), [searchKeyword]);
const shouldRenderTree = !showSearchInput || searchKeyword.trim().length > 0;
const usedPaths = useRef(markUsedPaths());
return (
<TreeContainerContext.Provider value={{usedPaths, forceOpenDirectories: forceExpandDirectories}}>
<div className="workspace-leaf">
<hr className="workspace-leaf-resize-handle"/>
<div className="workspace-leaf-content" datatype={datatype}>
{showSearchInput && (
<div style={{padding: '8px 12px'}}>
<input
aria-label="Search query"
type="text"
placeholder="Search pages"
value={searchKeyword}
onChange={(event) => onSearchKeywordChange(event.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
}}
/>
</div>
)}
<div className="nav-files-container node-insert-event" style={{position: "relative"}}>
<div>
{shouldRenderTree ? renderTree(tree.children, '', directoryFirstFn) : null}
</div>
</div>
</div>
</div>
</TreeContainerContext.Provider>
);
};
export default TreeContainer;
import {initTree, markUsedPaths, MIN_SEARCH_KEYWORD_LENGTH, renderTree} from "../utils/treeUtils.jsx";
export const TreeContainerContext = createContext({});
const directoryFirstFn = ([key1, value1], [key2, value2]) => {
if (value1.isFile !== value2.isFile) {
return value1.isFile ? 1 : -1;
}
return key1.localeCompare(key2);
};
const TreeContainer = ({
datatype,
searchKeyword = '',
forceExpandDirectories = false,
showSearchInput = false,
onSearchKeywordChange = (_nextKeyword) => {},
}) => {
const tree = useMemo(() => initTree(undefined, searchKeyword), [searchKeyword]);
const normalizedSearchLength = searchKeyword.normalize('NFC').trim().length;
const shouldRenderTree = !showSearchInput || normalizedSearchLength >= MIN_SEARCH_KEYWORD_LENGTH;
const usedPaths = useRef(markUsedPaths());
return (
<TreeContainerContext.Provider value={{usedPaths, forceOpenDirectories: forceExpandDirectories}}>
<div className="workspace-leaf">
<hr className="workspace-leaf-resize-handle"/>
<div className="workspace-leaf-content" datatype={datatype}>
{showSearchInput && (
<div style={{padding: '8px 12px'}}>
<input
aria-label="Search query"
type="text"
placeholder="Search pages"
value={searchKeyword}
onChange={(event) => onSearchKeywordChange(event.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
}}
/>
</div>
)}
<div className="nav-files-container node-insert-event" style={{position: "relative"}}>
<div>
{shouldRenderTree ? renderTree(tree.children, '', directoryFirstFn) : null}
</div>
</div>
</div>
</div>
</TreeContainerContext.Provider>
);
};
export default TreeContainer;

View file

@ -1,74 +1,75 @@
import {fireEvent, render, screen} from "@testing-library/react";
import TreeContainer from "./TreeContainer.jsx";
import {initTree, markUsedPaths, renderTree} from "../utils/treeUtils.jsx";
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
import {MemoryRouter} from "react-router-dom";
let tree = {
children: {}
};
describe('트리 컨테이너 컴포넌트 렌더링 시', () => {
beforeEach(() => {
import {fireEvent, render, screen} from "@testing-library/react";
import TreeContainer from "./TreeContainer.jsx";
import {initTree, markUsedPaths, renderTree} from "../utils/treeUtils.jsx";
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
import {MemoryRouter} from "react-router-dom";
let tree = {
children: {}
};
describe('트리 컨테이너 컴포넌트 렌더링 시', () => {
beforeEach(() => {
vi.mock('../utils/treeUtils.jsx', () => {
return {
MIN_SEARCH_KEYWORD_LENGTH: 2,
initTree: vi.fn(() => tree),
renderTree: vi.fn(),
markUsedPaths: vi.fn()
}
})
})
afterEach(() => {
vi.clearAllMocks();
})
it('트리 초기화 로직이 호출된다.', () => {
render(<MemoryRouter><TreeContainer/></MemoryRouter>)
expect(initTree).toHaveBeenCalled();
});
it('초기화한 트리 정보를 사용해 renderTree 함수가 호출된다.', () => {
tree = {
children: {
a: { children: {}}
}
}
render(<MemoryRouter><TreeContainer/></MemoryRouter>)
expect(renderTree).toHaveBeenCalled()
expect(renderTree.mock.calls[0][0]).toStrictEqual(tree.children);
});
it('markUsedPaths가 호출된다', () => {
render(<MemoryRouter><TreeContainer/></MemoryRouter>)
expect(markUsedPaths).toHaveBeenCalled();
});
it('검색어가 주어지면 트리 초기화 시 검색어를 함께 전달한다.', () => {
render(<MemoryRouter><TreeContainer searchKeyword="react"/></MemoryRouter>)
expect(initTree).toHaveBeenCalledWith(undefined, 'react');
});
it('검색 탭 모드면 검색 input이 트리 컨테이너 내부 상단에 렌더링된다.', () => {
render(<MemoryRouter><TreeContainer showSearchInput={true} searchKeyword=""/></MemoryRouter>);
expect(screen.getByLabelText('Search query')).toBeInTheDocument();
});
it('검색 input 값 변경 시 콜백이 호출된다.', () => {
const onSearchKeywordChange = vi.fn();
render(
<MemoryRouter>
<TreeContainer showSearchInput={true} searchKeyword="" onSearchKeywordChange={onSearchKeywordChange}/>
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText('Search query'), {target: {value: 'react'}});
expect(onSearchKeywordChange).toHaveBeenCalledWith('react');
});
it('검색 탭에서 검색어가 비어있으면 트리를 렌더링하지 않는다.', () => {
render(<MemoryRouter><TreeContainer showSearchInput={true} searchKeyword=""/></MemoryRouter>);
expect(renderTree).not.toHaveBeenCalled();
});
});
})
afterEach(() => {
vi.clearAllMocks();
})
it('트리 초기화 로직이 호출된다.', () => {
render(<MemoryRouter><TreeContainer/></MemoryRouter>)
expect(initTree).toHaveBeenCalled();
});
it('초기화한 트리 정보를 사용해 renderTree 함수가 호출된다.', () => {
tree = {
children: {
a: { children: {}}
}
}
render(<MemoryRouter><TreeContainer/></MemoryRouter>)
expect(renderTree).toHaveBeenCalled()
expect(renderTree.mock.calls[0][0]).toStrictEqual(tree.children);
});
it('markUsedPaths가 호출된다', () => {
render(<MemoryRouter><TreeContainer/></MemoryRouter>)
expect(markUsedPaths).toHaveBeenCalled();
});
it('검색어가 주어지면 트리 초기화 시 검색어를 함께 전달한다.', () => {
render(<MemoryRouter><TreeContainer searchKeyword="react"/></MemoryRouter>)
expect(initTree).toHaveBeenCalledWith(undefined, 'react');
});
it('검색 탭 모드면 검색 input이 트리 컨테이너 내부 상단에 렌더링된다.', () => {
render(<MemoryRouter><TreeContainer showSearchInput={true} searchKeyword=""/></MemoryRouter>);
expect(screen.getByLabelText('Search query')).toBeInTheDocument();
});
it('검색 input 값 변경 시 콜백이 호출된다.', () => {
const onSearchKeywordChange = vi.fn();
render(
<MemoryRouter>
<TreeContainer showSearchInput={true} searchKeyword="" onSearchKeywordChange={onSearchKeywordChange}/>
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText('Search query'), {target: {value: 'react'}});
expect(onSearchKeywordChange).toHaveBeenCalledWith('react');
});
it('검색 탭에서 검색어가 비어있으면 트리를 렌더링하지 않는다.', () => {
render(<MemoryRouter><TreeContainer showSearchInput={true} searchKeyword=""/></MemoryRouter>);
expect(renderTree).not.toHaveBeenCalled();
});
});

View file

@ -1,168 +1,240 @@
import fs from "fs";
import path from "path";
import mime from "mime";
import data from "../../stores/data.json";
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';
const markdownJsonFilePath = 'markdown-files.json';
const addToFileMap = (fileMap, key, value) => {
if (!fileMap[key]) {
fileMap[key] = [value];
} else {
fileMap[key].push(value);
}
}
function traverseImageRecursively(dir, fileMap) {
const files = fs.readdirSync(dir, { encoding: 'utf-8' });
const directories = [];
files.forEach((file) => {
const filePath = path.join(dir, file);
const mimeType = mime.getType(file);
if (fs.statSync(filePath).isDirectory()) {
directories.push(file);
} else {
if (mimeType && mimeType.startsWith('image/')) {
const fileNameKey = file.normalize('NFC');
const relativePath = path.relative(sourceDir, filePath).normalize('NFC')
addToFileMap(fileMap, fileNameKey, relativePath);
if (fileNameKey !== relativePath) {
addToFileMap(fileMap, relativePath, relativePath);
}
}
}
});
directories.forEach((file) => {
const filePath = path.join(dir, file);
traverseImageRecursively(filePath, fileMap);
})
return fileMap;
}
const markdownSearchJsonFilePath = 'markdown-search.json';
const addToFileMap = (fileMap, key, value) => {
if (!fileMap[key]) {
fileMap[key] = [value];
} else {
fileMap[key].push(value);
}
}
function traverseImageRecursively(dir, fileMap) {
const files = fs.readdirSync(dir, { encoding: 'utf-8' });
const directories = [];
files.forEach((file) => {
const filePath = path.join(dir, file);
const mimeType = mime.getType(file);
if (fs.statSync(filePath).isDirectory()) {
directories.push(file);
} else {
if (mimeType && mimeType.startsWith('image/')) {
const fileNameKey = file.normalize('NFC');
const relativePath = path.relative(sourceDir, filePath).normalize('NFC')
addToFileMap(fileMap, fileNameKey, relativePath);
if (fileNameKey !== relativePath) {
addToFileMap(fileMap, relativePath, relativePath);
}
}
}
});
directories.forEach((file) => {
const filePath = path.join(dir, file);
traverseImageRecursively(filePath, fileMap);
})
return fileMap;
}
function traverseFilesRecursively(dir, fileMap) {
const files = fs.readdirSync(dir, { encoding: 'utf-8' });
const directories = [];
files.forEach((file) => {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
directories.push(file);
} else if (file.endsWith('.md')) {
const fileNameKey = file.replace('.md', '').normalize('NFC');
const relativePath = path.relative(sourceDir, filePath).normalize('NFC')
const relativePathKey = relativePath.replace('.md', '')
addToFileMap(fileMap, fileNameKey, relativePath);
if (relativePathKey !== fileNameKey) {
addToFileMap(fileMap, relativePathKey, relativePath);
}
}
});
directories.forEach((file) => {
const filePath = path.join(dir, file);
traverseFilesRecursively(filePath, fileMap);
})
const files = fs.readdirSync(dir, { encoding: 'utf-8' });
const directories = [];
files.forEach((file) => {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
directories.push(file);
} else if (file.endsWith('.md')) {
const fileNameKey = file.replace('.md', '').normalize('NFC');
const relativePath = path.relative(sourceDir, filePath).normalize('NFC')
const relativePathKey = relativePath.replace('.md', '')
addToFileMap(fileMap, fileNameKey, relativePath);
if (relativePathKey !== fileNameKey) {
addToFileMap(fileMap, relativePathKey, relativePath);
}
}
});
directories.forEach((file) => {
const filePath = path.join(dir, file);
traverseFilesRecursively(filePath, fileMap);
})
return fileMap;
}
let tocMap = {};
export async function initTocMap() {
try {
const response = await fetch("/toc.json");
tocMap = await response.json();
} catch (error) {
console.error(error);
function normalizeSearchText(markdownText = '') {
return markdownText.normalize('NFC').replace(/\s+/g, ' ').trim();
}
function traverseMarkdownSearchRecursively(dir, searchMap) {
const files = fs.readdirSync(dir, { encoding: 'utf-8' });
for (const file of files) {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
traverseMarkdownSearchRecursively(filePath, searchMap);
continue;
}
if (!file.endsWith('.md')) {
continue;
}
const relativePath = path.relative(sourceDir, filePath).normalize('NFC');
const markdown = fs.readFileSync(filePath, 'utf-8');
searchMap[relativePath] = normalizeSearchText(markdown);
}
return searchMap;
}
export function getTocMap() {
return tocMap;
async function buildMarkdownSearchMapFromMarkdownFiles() {
const fallbackSearchMap = {};
const markdownPathSet = new Set();
for (const fileKey in getMarkdownFileMap()) {
const markdownPaths = markdownFileMap[fileKey] || [];
markdownPaths.forEach((markdownPath) => markdownPathSet.add(markdownPath));
}
const tasks = [...markdownPathSet].map(async (markdownPath) => {
try {
const response = await fetch('/sources/' + markdownPath);
const markdownText = await response.text();
fallbackSearchMap[markdownPath] = normalizeSearchText(markdownText);
} catch (error) {
console.log('Failed to fetch markdown source for search map.', markdownPath, error);
}
});
await Promise.all(tasks);
return fallbackSearchMap;
}
let tocMap = {};
export async function initTocMap() {
try {
const response = await fetch("/toc.json");
tocMap = await response.json();
} catch (error) {
console.error(error);
}
}
export function getTocMap() {
return tocMap;
}
let markdownFileMap = {};
export async function initMarkdownFileMap() {
try {
const response = await fetch("/" + markdownJsonFilePath)
markdownFileMap = await response.json();
} catch (error) {
console.log('Failed to initialize markdown file map.', error);
}
try {
const response = await fetch("/" + markdownJsonFilePath)
markdownFileMap = await response.json();
} catch (error) {
console.log('Failed to initialize markdown file map.', error);
}
}
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) {
const result = new Set();
for (const fileKey in getMarkdownFileMap()) {
markdownFileMap[fileKey].forEach(file => result.add(file));
}
markdownFileSet = result;
}
return markdownFileSet;
}
let imageFileMap = {};
export async function initImageFileMap() {
let markdownSearchMap = {};
export async function initMarkdownSearchMap() {
try {
const response = await fetch("/" + imageJsonFilePath)
imageFileMap = await response.json();
const response = await fetch('/' + markdownSearchJsonFilePath);
markdownSearchMap = await response.json();
} catch (error) {
console.log('Failed to initialize image file map.', error);
console.log('Failed to initialize markdown search map.', error);
if (Object.keys(markdownFileMap).length === 0) {
await initMarkdownFileMap();
}
markdownSearchMap = await buildMarkdownSearchMapFromMarkdownFiles();
}
}
export function getImageFileMap() {
return imageFileMap;
export function getMarkdownSearchMap() {
return markdownSearchMap;
}
export function createImageMapToJson() {
const fileMap = {};
traverseImageRecursively(sourceDir, fileMap);
fs.writeFileSync('public/' + imageJsonFilePath, JSON.stringify(fileMap, null, 2), { encoding: 'utf-8' });
imageFileMap = fileMap;
}
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) {
const result = new Set();
for (const fileKey in getMarkdownFileMap()) {
markdownFileMap[fileKey].forEach(file => result.add(file));
}
markdownFileSet = result;
}
return markdownFileSet;
}
let imageFileMap = {};
export async function initImageFileMap() {
try {
const response = await fetch("/" + imageJsonFilePath)
imageFileMap = await response.json();
} catch (error) {
console.log('Failed to initialize image file map.', error);
}
}
export function getImageFileMap() {
return imageFileMap;
}
export function createImageMapToJson() {
const fileMap = {};
traverseImageRecursively(sourceDir, fileMap);
fs.writeFileSync('public/' + imageJsonFilePath, JSON.stringify(fileMap, null, 2), { encoding: 'utf-8' });
imageFileMap = fileMap;
}
export function createFileMapToJson() {
const fileMap = {};
traverseFilesRecursively(sourceDir, fileMap);
fs.writeFileSync('public/' + markdownJsonFilePath, JSON.stringify(fileMap, null, 2), { encoding: 'utf-8' });
markdownFileMap = fileMap;
const fileMap = {};
traverseFilesRecursively(sourceDir, fileMap);
fs.writeFileSync('public/' + markdownJsonFilePath, JSON.stringify(fileMap, null, 2), { encoding: 'utf-8' });
markdownFileMap = fileMap;
}
export function initSourceDirectory() {
if (!fs.existsSync(sourceDir)) {
fs.mkdirSync(sourceDir, { recursive: true });
}
export function createMarkdownSearchMapToJson() {
const searchMap = {};
traverseMarkdownSearchRecursively(sourceDir, searchMap);
fs.writeFileSync('public/' + markdownSearchJsonFilePath, JSON.stringify(searchMap, null, 2), { encoding: 'utf-8' });
markdownSearchMap = searchMap;
}
export function initSourceDirectory() {
if (!fs.existsSync(sourceDir)) {
fs.mkdirSync(sourceDir, { recursive: true });
}
}

View file

@ -1,331 +1,412 @@
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import {
createFileMapToJson,
createImageMapToJson,
createMarkdownSearchMapToJson,
getImageFileMap,
getIndexFilePath,
getMarkdownFileMap,
getMarkdownSearchMap,
initImageFileMap,
initMarkdownFileMap,
initMarkdownSearchMap,
normalizeIndexFilePath,
} from "./fileUtils.js";
import fs from "fs";
import fs from "fs";
const sourceDir = 'public/sources';
const imageJsonFilePath = 'image-files.json';
const markdownJsonFilePath = 'markdown-files.json'
const options = { encoding: 'utf-8' };
const jsonOf = (expectedFileList) => {return JSON.stringify(expectedFileList, null, 2)};
let mockFiles;
let expectedFileMap;
beforeAll(() => {
vi.mock('fs')
vi.mock('fetch')
fs.readdirSync.mockImplementation((dir) => {
return mockFiles[dir] || [];
})
fs.statSync.mockImplementation((filePath) => ({
isDirectory: () => mockFiles[filePath] !== undefined,
}));
fs.writeFileSync.mockImplementation(() => {});
fs.readFileSync.mockImplementation(() => {});
})
afterAll(() => {
vi.clearAllMocks();
})
describe('이미지 맵 JSON 생성 요청 시', () => {
it('이미지 타입의 파일만 맵에 포함된다.', () => {
// given
mockFiles = {
[sourceDir] : ['file1.png', 'file2.txt', 'file3.md', 'file4.pngpng', 'file5.avi'],
};
expectedFileMap = {
'file1.png' : ['file1.png']
};
// when
createImageMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + imageJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('재귀적으로 이미지를 탐색해 맵에 담는다.', () => {
// given
mockFiles = {
[sourceDir] : ['file1.png', 'dir1'],
[sourceDir + '/dir1']: ['file2.png', 'dir2'],
[sourceDir + '/dir1/dir2']: ['file3.jpeg', 'dir3'],
[sourceDir + '/dir1/dir2/dir3']: ['file4.gif'],
};
expectedFileMap = {
'file1.png': ['file1.png'],
'file2.png' : ['dir1/file2.png'],
'dir1/file2.png': ['dir1/file2.png'],
'file3.jpeg': ['dir1/dir2/file3.jpeg'],
'dir1/dir2/file3.jpeg': ['dir1/dir2/file3.jpeg'],
'file4.gif': ['dir1/dir2/dir3/file4.gif'],
'dir1/dir2/dir3/file4.gif': ['dir1/dir2/dir3/file4.gif'],
};
// when
createImageMapToJson()
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + imageJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('파일명이 중복되면 엔트리의 리스트에 삽입된다.', () => {
// given
mockFiles = {
[sourceDir]: ['file1.png', 'dir1'],
[sourceDir + '/dir1']: ['file1.png'],
};
expectedFileMap = {
'file1.png' : ['file1.png', 'dir1/file1.png'],
'dir1/file1.png' : ['dir1/file1.png'],
};
// when
createImageMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + imageJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('이미지 맵을 업데이트한다.', () => {
mockFiles = {
[sourceDir] : ['file1.png'],
};
expectedFileMap = {
'file1.png' : ['file1.png']
};
createImageMapToJson();
expect(getImageFileMap()).toStrictEqual(expectedFileMap);
});
})
describe('파일 목록 JSON 생성 요청 시', () => {
it('md 확장자를 가진 파일만 목록에 포함된다.', () => {
// given
mockFiles = {
[sourceDir] : ['file1.md', 'file2.txt', 'file3.png', 'file4.mdmd', 'file5.md6'],
};
expectedFileMap = {
'file1' : ['file1.md']
};
// when
createFileMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('재귀적으로 파일을 탐색해 목록에 포함한다.', () => {
// given
mockFiles = {
[sourceDir]: ['file1.md', 'dir1'],
[sourceDir + '/dir1']: ['file2.md', 'dir2'],
[sourceDir + '/dir1/dir2']: ['file3.md', 'dir3'],
[sourceDir + '/dir1/dir2/dir3']: ['file4.md'],
};
expectedFileMap = {
'file1' : ['file1.md'],
'file2' : ['dir1/file2.md'],
'dir1/file2' : ['dir1/file2.md'],
'file3' : ['dir1/dir2/file3.md'],
'dir1/dir2/file3' : ['dir1/dir2/file3.md'],
'file4' : ['dir1/dir2/dir3/file4.md'],
'dir1/dir2/dir3/file4' : ['dir1/dir2/dir3/file4.md'],
};
// when
createFileMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('파일명이 중복되면 엔트리의 리스트에 삽입된다.', () => {
// given
mockFiles = {
[sourceDir]: ['file1.md', 'dir1'],
[sourceDir + '/dir1']: ['file1.md'],
};
expectedFileMap = {
'file1' : ['file1.md', 'dir1/file1.md'],
'dir1/file1' : ['dir1/file1.md'],
};
// when
createFileMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('마크다운 맵을 업데이트한다.', () => {
mockFiles = {
[sourceDir] : ['file1.md'],
};
expectedFileMap = {
'file1' : ['file1.md']
};
createFileMapToJson();
expect(getMarkdownFileMap()).toStrictEqual(expectedFileMap);
});
});
const markdownSearchJsonFilePath = 'markdown-search.json'
const options = { encoding: 'utf-8' };
const jsonOf = (expectedFileList) => {return JSON.stringify(expectedFileList, null, 2)};
let mockFiles;
let expectedFileMap;
beforeAll(() => {
vi.mock('fs')
vi.mock('fetch')
fs.readdirSync.mockImplementation((dir) => {
return mockFiles[dir] || [];
})
fs.statSync.mockImplementation((filePath) => ({
isDirectory: () => mockFiles[filePath] !== undefined,
}));
fs.writeFileSync.mockImplementation(() => {});
fs.readFileSync.mockImplementation(() => {});
})
afterAll(() => {
vi.clearAllMocks();
})
describe('이미지 맵 JSON 생성 요청 시', () => {
it('이미지 타입의 파일만 맵에 포함된다.', () => {
// given
mockFiles = {
[sourceDir] : ['file1.png', 'file2.txt', 'file3.md', 'file4.pngpng', 'file5.avi'],
};
expectedFileMap = {
'file1.png' : ['file1.png']
};
// when
createImageMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + imageJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('재귀적으로 이미지를 탐색해 맵에 담는다.', () => {
// given
mockFiles = {
[sourceDir] : ['file1.png', 'dir1'],
[sourceDir + '/dir1']: ['file2.png', 'dir2'],
[sourceDir + '/dir1/dir2']: ['file3.jpeg', 'dir3'],
[sourceDir + '/dir1/dir2/dir3']: ['file4.gif'],
};
expectedFileMap = {
'file1.png': ['file1.png'],
'file2.png' : ['dir1/file2.png'],
'dir1/file2.png': ['dir1/file2.png'],
'file3.jpeg': ['dir1/dir2/file3.jpeg'],
'dir1/dir2/file3.jpeg': ['dir1/dir2/file3.jpeg'],
'file4.gif': ['dir1/dir2/dir3/file4.gif'],
'dir1/dir2/dir3/file4.gif': ['dir1/dir2/dir3/file4.gif'],
};
// when
createImageMapToJson()
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + imageJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('파일명이 중복되면 엔트리의 리스트에 삽입된다.', () => {
// given
mockFiles = {
[sourceDir]: ['file1.png', 'dir1'],
[sourceDir + '/dir1']: ['file1.png'],
};
expectedFileMap = {
'file1.png' : ['file1.png', 'dir1/file1.png'],
'dir1/file1.png' : ['dir1/file1.png'],
};
// when
createImageMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + imageJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('이미지 맵을 업데이트한다.', () => {
mockFiles = {
[sourceDir] : ['file1.png'],
};
expectedFileMap = {
'file1.png' : ['file1.png']
};
createImageMapToJson();
expect(getImageFileMap()).toStrictEqual(expectedFileMap);
});
})
describe('파일 목록 JSON 생성 요청 시', () => {
it('md 확장자를 가진 파일만 목록에 포함된다.', () => {
// given
mockFiles = {
[sourceDir] : ['file1.md', 'file2.txt', 'file3.png', 'file4.mdmd', 'file5.md6'],
};
expectedFileMap = {
'file1' : ['file1.md']
};
// when
createFileMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('재귀적으로 파일을 탐색해 목록에 포함한다.', () => {
// given
mockFiles = {
[sourceDir]: ['file1.md', 'dir1'],
[sourceDir + '/dir1']: ['file2.md', 'dir2'],
[sourceDir + '/dir1/dir2']: ['file3.md', 'dir3'],
[sourceDir + '/dir1/dir2/dir3']: ['file4.md'],
};
expectedFileMap = {
'file1' : ['file1.md'],
'file2' : ['dir1/file2.md'],
'dir1/file2' : ['dir1/file2.md'],
'file3' : ['dir1/dir2/file3.md'],
'dir1/dir2/file3' : ['dir1/dir2/file3.md'],
'file4' : ['dir1/dir2/dir3/file4.md'],
'dir1/dir2/dir3/file4' : ['dir1/dir2/dir3/file4.md'],
};
// when
createFileMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('파일명이 중복되면 엔트리의 리스트에 삽입된다.', () => {
// given
mockFiles = {
[sourceDir]: ['file1.md', 'dir1'],
[sourceDir + '/dir1']: ['file1.md'],
};
expectedFileMap = {
'file1' : ['file1.md', 'dir1/file1.md'],
'dir1/file1' : ['dir1/file1.md'],
};
// when
createFileMapToJson();
// then
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownJsonFilePath,
jsonOf(expectedFileMap),
options
);
});
it('마크다운 맵을 업데이트한다.', () => {
mockFiles = {
[sourceDir] : ['file1.md'],
};
expectedFileMap = {
'file1' : ['file1.md']
};
createFileMapToJson();
expect(getMarkdownFileMap()).toStrictEqual(expectedFileMap);
});
});
describe('파일 맵 초기화 요청 시', () => {
beforeEach(() => {
global.fetch = vi.fn(() => {
return Promise.resolve({
json: () => Promise.resolve(expectedFileMap)
});
}
)
})
it('fetch를 통해 마크다운 JSON 파일을 로드한다.', async () => {
expectedFileMap = {'file1': 'file1'};
await initMarkdownFileMap()
expect(fetch).toHaveBeenCalledWith('/' + markdownJsonFilePath)
});
it('fetch에 성공하면 내부 객체를 업데이트한다.', async () => {
expectedFileMap = {'file2': 'file2'};
await initMarkdownFileMap()
const markdownFileMap = getMarkdownFileMap()
expect(markdownFileMap).toEqual(expectedFileMap)
});
it('fetch에 실패하면 내부 객체를 업데이트하지 않는다.', async () => {
expectedFileMap = {'file3': 'file3'};
global.fetch = vi.fn(() => Promise.reject(new Error('Fetch error')));
await initMarkdownFileMap()
const markdownFileMap = getMarkdownFileMap()
expect(markdownFileMap).not.toBe(expectedFileMap)
});
});
describe('검색 맵 초기화 요청 시', () => {
beforeEach(() => {
global.fetch = vi.fn(() => {
global.fetch = vi.fn((url) => {
if (url === '/' + markdownSearchJsonFilePath) {
return Promise.resolve({
json: () => Promise.resolve({'doc.md': 'cached index'})
});
}
if (url === '/doc.md') {
return Promise.resolve({
text: () => Promise.resolve('apple and 안녕하세요')
});
}
return Promise.resolve({
json: () => Promise.resolve(expectedFileMap)
});
}
)
})
it('fetch를 통해 마크다운 JSON 파일을 로드한다.', async () => {
expectedFileMap = {'file1': 'file1'};
await initMarkdownFileMap()
expect(fetch).toHaveBeenCalledWith('/' + markdownJsonFilePath)
json: () => Promise.resolve(expectedFileMap),
text: () => Promise.resolve(''),
});
});
});
it('fetch에 성공하면 내부 객체를 업데이트한다.', async () => {
expectedFileMap = {'file2': 'file2'};
await initMarkdownFileMap()
const markdownFileMap = getMarkdownFileMap()
expect(markdownFileMap).toEqual(expectedFileMap)
});
it('fetch에 실패하면 내부 객체를 업데이트하지 않는다.', async () => {
expectedFileMap = {'file3': 'file3'};
global.fetch = vi.fn(() => Promise.reject(new Error('Fetch error')));
await initMarkdownFileMap()
const markdownFileMap = getMarkdownFileMap()
expect(markdownFileMap).not.toBe(expectedFileMap)
});
});
describe('이미지 맵 초기화 요청 시', () => {
let expectedImageMap = {'default': 'default'};
beforeEach(() => {
global.fetch = vi.fn(() => {
return Promise.resolve({
json: () => Promise.resolve(expectedImageMap)
});
}
)
})
it('fetch를 통해 이미지 JSON 파일을 로드한다.', async () => {
expectedImageMap = {'image1': 'image1'};
await initImageFileMap()
expect(fetch).toHaveBeenCalledWith('/' + imageJsonFilePath)
it('검색 맵 JSON fetch 성공 시 내부 검색 맵을 업데이트한다.', async () => {
await initMarkdownSearchMap();
expect(getMarkdownSearchMap()).toEqual({'doc.md': 'cached index'});
});
it('fetch에 성공하면 내부 객체를 업데이트한다.', async () => {
expectedImageMap = {'image2': 'image2'};
await initImageFileMap()
const imageFileMap = getImageFileMap()
expect(imageFileMap).toEqual(expectedImageMap)
});
it('fetch에 실패하면 내부 객체를 업데이트하지 않는다.', async () => {
expectedImageMap = {'image3': 'image3'};
global.fetch = vi.fn(() => Promise.reject(new Error('Fetch error')));
await initImageFileMap()
const imageFileMap = getImageFileMap()
expect(imageFileMap).not.toBe(expectedFileMap)
});
});
describe('파일 셋 조회 요청 시', () => {
beforeEach(() => {
it('검색 맵 JSON fetch 실패 시 마크다운 파일 본문 fetch로 검색 맵을 구성한다.', async () => {
vi.resetModules();
global.fetch = vi.fn(() => {
global.fetch = vi.fn((url) => {
if (url === '/' + markdownSearchJsonFilePath) {
return Promise.reject(new Error('missing index'));
}
if (url === '/' + markdownJsonFilePath) {
return Promise.resolve({
json: () => Promise.resolve(expectedFileMap)
json: () => Promise.resolve({doc: ['doc.md']})
});
}
)
})
let expectedFileSet;
it('파일 셋이 초기화되지 않은 경우 파일 맵을 전체 순회하며 파일 셋에 담는다.', async () => {
const { getMarkdownFileSet, initMarkdownFileMap } = await import('./fileUtils.js')
expectedFileMap = {'file1': ['file1', 'dir/file1'], 'dir/file1': ['dir/file1']}
expectedFileSet = new Set(['file1', 'dir/file1'])
await initMarkdownFileMap()
const resultSet = getMarkdownFileSet()
expect(resultSet).toStrictEqual(expectedFileSet);
});
it('파일 셋이 초기화된 경우 캐시된 파일 셋을 반환한다.', async () => {
const { getMarkdownFileSet, initMarkdownFileMap } = await import('./fileUtils.js')
expectedFileMap = {}
await initMarkdownFileMap()
const initSet = getMarkdownFileSet()
if (url === '/sources/doc.md') {
return Promise.resolve({
text: () => Promise.resolve('apple and 안녕하세요')
});
}
return Promise.resolve({
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
});
});
expectedFileMap = {'file2': ['file2', 'dir/file2'], 'dir/file2': ['dir/file2']}
expectedFileSet = new Set(['file2', 'dir/file2'])
await initMarkdownFileMap()
const cachedSet = getMarkdownFileSet()
expect(cachedSet).not.toStrictEqual(expectedFileSet);
expect(cachedSet).toStrictEqual(initSet);
const {initMarkdownFileMap, initMarkdownSearchMap, getMarkdownSearchMap} = await import('./fileUtils.js');
await initMarkdownFileMap();
await initMarkdownSearchMap();
expect(getMarkdownSearchMap()).toEqual({'doc.md': 'apple and 안녕하세요'});
});
});
describe('index 파일 경로 정규화 요청 시', () => {
it('sourceDir를 포함한 index 경로를 source 기준 상대 경로로 정규화한다.', () => {
const normalized = normalizeIndexFilePath('!new-blog/index.md', '!new-blog');
expect(normalized).toBe('index.md');
});
describe('검색 맵 JSON 생성 요청 시', () => {
it('재귀적으로 마크다운 파일 본문을 탐색해 검색 맵 JSON을 생성한다.', () => {
mockFiles = {
[sourceDir]: ['doc.md'],
};
fs.readFileSync.mockImplementation((filePath) => {
if (filePath === sourceDir + '/doc.md') {
return ' apple\n\n안녕하세요 ';
}
return '';
});
it('이미 source 기준 상대 경로면 그대로 반환한다.', () => {
const normalized = normalizeIndexFilePath('index.md', '!new-blog');
expect(normalized).toBe('index.md');
});
createMarkdownSearchMapToJson();
it('설정 데이터에 index 경로가 없으면 빈 값을 반환한다.', () => {
expect(getIndexFilePath()).toBe('');
expect(fs.writeFileSync).toHaveBeenCalledWith(
'public/' + markdownSearchJsonFilePath,
jsonOf({'doc.md': 'apple 안녕하세요'}),
options,
);
});
});
describe('이미지 맵 초기화 요청 시', () => {
let expectedImageMap = {'default': 'default'};
beforeEach(() => {
global.fetch = vi.fn(() => {
return Promise.resolve({
json: () => Promise.resolve(expectedImageMap)
});
}
)
})
it('fetch를 통해 이미지 JSON 파일을 로드한다.', async () => {
expectedImageMap = {'image1': 'image1'};
await initImageFileMap()
expect(fetch).toHaveBeenCalledWith('/' + imageJsonFilePath)
});
it('fetch에 성공하면 내부 객체를 업데이트한다.', async () => {
expectedImageMap = {'image2': 'image2'};
await initImageFileMap()
const imageFileMap = getImageFileMap()
expect(imageFileMap).toEqual(expectedImageMap)
});
it('fetch에 실패하면 내부 객체를 업데이트하지 않는다.', async () => {
expectedImageMap = {'image3': 'image3'};
global.fetch = vi.fn(() => Promise.reject(new Error('Fetch error')));
await initImageFileMap()
const imageFileMap = getImageFileMap()
expect(imageFileMap).not.toBe(expectedFileMap)
});
});
describe('파일 셋 조회 요청 시', () => {
beforeEach(() => {
vi.resetModules();
global.fetch = vi.fn(() => {
return Promise.resolve({
json: () => Promise.resolve(expectedFileMap)
});
}
)
})
let expectedFileSet;
it('파일 셋이 초기화되지 않은 경우 파일 맵을 전체 순회하며 파일 셋에 담는다.', async () => {
const { getMarkdownFileSet, initMarkdownFileMap } = await import('./fileUtils.js')
expectedFileMap = {'file1': ['file1', 'dir/file1'], 'dir/file1': ['dir/file1']}
expectedFileSet = new Set(['file1', 'dir/file1'])
await initMarkdownFileMap()
const resultSet = getMarkdownFileSet()
expect(resultSet).toStrictEqual(expectedFileSet);
});
it('파일 셋이 초기화된 경우 캐시된 파일 셋을 반환한다.', async () => {
const { getMarkdownFileSet, initMarkdownFileMap } = await import('./fileUtils.js')
expectedFileMap = {}
await initMarkdownFileMap()
const initSet = getMarkdownFileSet()
expectedFileMap = {'file2': ['file2', 'dir/file2'], 'dir/file2': ['dir/file2']}
expectedFileSet = new Set(['file2', 'dir/file2'])
await initMarkdownFileMap()
const cachedSet = getMarkdownFileSet()
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,109 +1,116 @@
import React from 'react'
import {getIndexFilePath, getMarkdownFileSet} from "./file/fileUtils.js";
import {getIndexFilePath, getMarkdownFileSet, getMarkdownSearchMap} from "./file/fileUtils.js";
import TreeItem from "../components/TreeItem.jsx";
const calculateCounts = (node) => {
let total = 0;
Object.values(node).forEach((child) => {
if (child.children && !child.isFile) {
child.count += calculateCounts(child.children);
}
total += child.count;
});
return total;
};
const buildTree = (paths) => {
const root = {};
paths.forEach((path) => {
const parts = path.split('/');
let current = root;
parts.forEach((part, index) => {
if (!current[part]) {
current[part] = {
children: {},
count: 0,
isFile: index === parts.length - 1,
};
}
if (index === parts.length - 1) {
current[part].count += 1;
}
current = current[part].children;
});
});
const tree = {};
tree.children = root;
tree.count = calculateCounts(root);
return tree;
};
export const MIN_SEARCH_KEYWORD_LENGTH = 2;
const calculateCounts = (node) => {
let total = 0;
Object.values(node).forEach((child) => {
if (child.children && !child.isFile) {
child.count += calculateCounts(child.children);
}
total += child.count;
});
return total;
};
const buildTree = (paths) => {
const root = {};
paths.forEach((path) => {
const parts = path.split('/');
let current = root;
parts.forEach((part, index) => {
if (!current[part]) {
current[part] = {
children: {},
count: 0,
isFile: index === parts.length - 1,
};
}
if (index === parts.length - 1) {
current[part].count += 1;
}
current = current[part].children;
});
});
const tree = {};
tree.children = root;
tree.count = calculateCounts(root);
return tree;
};
export const filterPathsByKeyword = (paths, keyword = '') => {
const normalizedKeyword = keyword.normalize('NFC').trim().toLowerCase();
if (!normalizedKeyword) {
return new Set(paths);
}
if (normalizedKeyword.length < MIN_SEARCH_KEYWORD_LENGTH) {
return new Set();
}
const markdownSearchMap = getMarkdownSearchMap() || {};
const result = new Set();
for (const path of paths) {
const normalizedPath = path.normalize('NFC').toLowerCase();
if (normalizedPath.includes(normalizedKeyword)) {
const markdownContent = (markdownSearchMap[path] || '').normalize('NFC').toLowerCase();
if (normalizedPath.includes(normalizedKeyword) || markdownContent.includes(normalizedKeyword)) {
result.add(path);
}
}
return result;
}
export const initTree = (indexMarkdownPath = getIndexFilePath(), searchKeyword = '') => {
const fileSet = new Set(getMarkdownFileSet());
const indexFilePath = (indexMarkdownPath || '').normalize('NFC');
if (indexFilePath) {
fileSet.delete(indexFilePath);
}
const filteredPaths = filterPathsByKeyword(fileSet, searchKeyword);
return buildTree(filteredPaths)
}
export const renderTree = (nodes, basePath = '', compareFn = () => {}) => {
const sortedNodes = Object.entries(nodes).sort(compareFn)
return (
<>
{/* TODO 아마 크기 조절용인듯? 크기 조절 기능은 나중에 추가*/}
<div style={{width: '353px', height: '0.1px', marginBottom: '0px'}}></div>
{
sortedNodes.map(([key, value]) => {
const path = `${basePath}/${key}`;
return (
<React.Fragment key={path}>
{!value.isFile ? (
<TreeItem title={key + " (" + value.count + ")"} isDirectory={true} path={path}>
{renderTree(value.children, path, compareFn)}
</TreeItem>
) : (
<TreeItem title={key.replace('.md', '')} path={path}/>
)}
</React.Fragment>
);
}
)
}
</>
)
};
export const markUsedPaths = (path = location.pathname) => {
const usedPaths = {};
const currentPath = decodeURIComponent(path);
if (currentPath) {
const pathParts = currentPath.split('/').filter(Boolean);
let subPath = '';
pathParts.forEach((part) => {
subPath += `/${part}`;
usedPaths[subPath] = true;
});
return usedPaths
}
return usedPaths
}
return result;
}
export const initTree = (indexMarkdownPath = getIndexFilePath(), searchKeyword = '') => {
const fileSet = new Set(getMarkdownFileSet());
const indexFilePath = (indexMarkdownPath || '').normalize('NFC');
if (indexFilePath) {
fileSet.delete(indexFilePath);
}
const filteredPaths = filterPathsByKeyword(fileSet, searchKeyword);
return buildTree(filteredPaths)
}
export const renderTree = (nodes, basePath = '', compareFn = () => {}) => {
const sortedNodes = Object.entries(nodes).sort(compareFn)
return (
<>
{/* TODO 아마 크기 조절용인듯? 크기 조절 기능은 나중에 추가*/}
<div style={{width: '353px', height: '0.1px', marginBottom: '0px'}}></div>
{
sortedNodes.map(([key, value]) => {
const path = `${basePath}/${key}`;
return (
<React.Fragment key={path}>
{!value.isFile ? (
<TreeItem title={key + " (" + value.count + ")"} isDirectory={true} path={path}>
{renderTree(value.children, path, compareFn)}
</TreeItem>
) : (
<TreeItem title={key.replace('.md', '')} path={path}/>
)}
</React.Fragment>
);
}
)
}
</>
)
};
export const markUsedPaths = (path = location.pathname) => {
const usedPaths = {};
const currentPath = decodeURIComponent(path);
if (currentPath) {
const pathParts = currentPath.split('/').filter(Boolean);
let subPath = '';
pathParts.forEach((part) => {
subPath += `/${part}`;
usedPaths[subPath] = true;
});
return usedPaths
}
return usedPaths
}

View file

@ -1,133 +1,136 @@
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
import {initTree, markUsedPaths, renderTree} from "./treeUtils.jsx";
import {render, screen} from "@testing-library/react";
import {MemoryRouter} from "react-router-dom";
import {render, screen} from "@testing-library/react";
import {MemoryRouter} from "react-router-dom";
let fileSet;
let searchContentMap;
let expectedTree;
beforeAll(() => {
beforeAll(() => {
vi.mock('./file/fileUtils.js', () => {
return {
getMarkdownFileSet: () => fileSet,
getMarkdownSearchMap: () => searchContentMap,
getIndexFilePath: () => '',
}
})
vi.mock('../components/TreeItem', () => ({
default: ({title, children}) => <div><div>{title}</div><div>{children}</div></div>}))
});
afterAll(() => {
vi.clearAllMocks();
})
describe('initTree 호출 시', () => {
it('조회한 파일 셋의 내용으로 트리를 구축한다.', () => {
fileSet = new Set(['file1']);
expectedTree = {
children: {
'file1' : {children: {}, count: 1, isFile: true},
},
count: fileSet.size,
}
const tree = initTree();
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('/를 기준으로 path 정보를 분할해 트리를 구성한다.', () => {
fileSet = new Set(['dir/file1']);
expectedTree = {
children: {
'dir': {
children: {
'file1' : {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false
},
},
count: fileSet.size,
}
const tree = initTree();
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('트리 노드가 디렉토리면 자식 정보와 자식의 수가 포함된다.', () => {
fileSet = new Set(['dir/file2', 'dir/file3']);
expectedTree = {
children: {
'dir': {
children: {
'file2' : {children: {}, count: 1, isFile: true},
'file3' : {children: {}, count: 1, isFile: true}
},
count: 2,
isFile: false
},
},
count: fileSet.size,
}
const tree = initTree();
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('자식의 수를 계산할 때 중첩된 자식의 수까지 모두 계산한다.', () => {
fileSet = new Set([
'dir1/file1',
'dir1/dir2/file2',
]);
expectedTree = {
children: {
'dir1': {
children: {
'dir2': {
children: {
'file2': {children: {}, count: 1, isFile: true},
},
count: 1, isFile: false,
},
'file1': {children: {}, count: 1, isFile: true},
},
count: 2,
isFile: false,
},
},
'count': fileSet.size,
};
const tree = 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);
});
it('검색어가 전달되면 해당 키워드를 포함한 경로만 트리에 포함한다.', () => {
vi.mock('../components/TreeItem', () => ({
default: ({title, children}) => <div><div>{title}</div><div>{children}</div></div>}))
});
afterAll(() => {
vi.clearAllMocks();
})
describe('initTree 호출 시', () => {
it('조회한 파일 셋의 내용으로 트리를 구축한다.', () => {
fileSet = new Set(['file1']);
expectedTree = {
children: {
'file1' : {children: {}, count: 1, isFile: true},
},
count: fileSet.size,
}
const tree = initTree();
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('/를 기준으로 path 정보를 분할해 트리를 구성한다.', () => {
fileSet = new Set(['dir/file1']);
expectedTree = {
children: {
'dir': {
children: {
'file1' : {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false
},
},
count: fileSet.size,
}
const tree = initTree();
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('트리 노드가 디렉토리면 자식 정보와 자식의 수가 포함된다.', () => {
fileSet = new Set(['dir/file2', 'dir/file3']);
expectedTree = {
children: {
'dir': {
children: {
'file2' : {children: {}, count: 1, isFile: true},
'file3' : {children: {}, count: 1, isFile: true}
},
count: 2,
isFile: false
},
},
count: fileSet.size,
}
const tree = initTree();
expect(tree.count).toBe(fileSet.size);
expect(tree).toStrictEqual(expectedTree)
});
it('자식의 수를 계산할 때 중첩된 자식의 수까지 모두 계산한다.', () => {
fileSet = new Set([
'dir1/file1',
'dir1/dir2/file2',
]);
expectedTree = {
children: {
'dir1': {
children: {
'dir2': {
children: {
'file2': {children: {}, count: 1, isFile: true},
},
count: 1, isFile: false,
},
'file1': {children: {}, count: 1, isFile: true},
},
count: 2,
isFile: false,
},
},
'count': fileSet.size,
};
const tree = 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);
});
it('검색어가 전달되면 해당 키워드를 포함한 경로만 트리에 포함한다.', () => {
fileSet = new Set([
'Development/Frontend/React/useState.md',
'Development/Backend/Java/ArrayList.md',
]);
searchContentMap = {};
const tree = initTree('', 'react');
expect(tree.count).toBe(1);
@ -135,101 +138,139 @@ describe('initTree 호출 시', () => {
expect(tree.children.Development.children.Backend).toBeUndefined();
});
});
it('검색어 길이가 2글자 미만이면 검색 결과를 표시하지 않는다.', () => {
fileSet = new Set([
'Development/Frontend/React/useState.md',
'Development/Backend/Java/ArrayList.md',
]);
searchContentMap = {};
describe('renderTree 호출 시', () => {
let inputTree;
it('인자로 넘겨진 트리 노드가 컴포넌트로 렌더링된다.', () => {
const nodeTitle1 = 'file1';
const nodeTitle2 = 'file2';
inputTree = {
[nodeTitle1] : {children: {}, count: 1, isFile: true},
[nodeTitle2] : {children: {}, count: 1, isFile: true},
}
render(<MemoryRouter>{renderTree(inputTree)}</MemoryRouter>)
expect(screen.getByText(nodeTitle1)).toBeInTheDocument();
expect(screen.getByText(nodeTitle2)).toBeInTheDocument();
});
it('트리 노드가 파일 노드가 아니면 자식의 수가 타이틀에 표시된다.', () => {
const nodeKey = 'dir'
const nodeCount = 3;
inputTree = {
[nodeKey] : {
children: {},
count: nodeCount,
isFile: false
},
}
const expectedTitle = `${nodeKey} (${nodeCount})`
render(<MemoryRouter>{renderTree(inputTree)}</MemoryRouter>)
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('트리 노드가 파일 노드가 아니면 재귀적으로 렌더링된다.', () => {
inputTree = {
'dir1' : {
children: {
'dir2': {
children: {
'file2': {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false
},
'file1': {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false
},
}
render(<MemoryRouter>{renderTree(inputTree)}</MemoryRouter>)
const dir1Children = screen.getByText('dir1 (1)').nextSibling;
const file1 = screen.getByText('file1');
const dir2 = screen.getByText('dir2 (1)');
const dir2Children = dir2.nextSibling;
const file2 = screen.getByText('file2');
expect(dir1Children).toContainElement(file1);
expect(dir1Children).toContainElement(dir2);
expect(dir1Children).toContainElement(file2);
expect(dir2Children).toContainElement(file2);
expect(dir2Children).not.toContainElement(file1);
});
it('인자로 전달된 정렬 기준으로 정렬된 트리로 렌더링한다.', () => {
inputTree = {
'1' : {children: {}, count: 1, isFile: true},
'2' : {children: {}, count: 1, isFile: true},
}
render(<MemoryRouter data-testid='container'>{renderTree(inputTree, '', ([k1], [k2]) => {
return k2.localeCompare(k1);
})}</MemoryRouter>)
const el1 = screen.getByText('1');
const el2 = screen.getByText('2');
const el1Parent = el1.parentElement;
const el2Parent = el2.parentElement;
const ancestor = el1Parent.parentElement;
expect(ancestor.children[1]).toBe(el2Parent)
expect(ancestor.children[2]).toBe(el1Parent)
});
});
const tree = initTree('', 'r');
expect(tree.count).toBe(0);
expect(tree.children).toStrictEqual({});
});
it('검색어가 문서 본문에 부분 일치하면 해당 문서를 검색 결과에 포함한다.', () => {
fileSet = new Set(['posts/greeting.md', 'posts/other.md']);
searchContentMap = {
'posts/greeting.md': '오늘의 인사말은 안녕하세요 입니다.',
'posts/other.md': '테스트 문서입니다.',
};
const tree = initTree('', '하세');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['greeting.md']).toBeDefined();
expect(tree.children.posts.children['other.md']).toBeUndefined();
});
it('영문 검색어가 문서 본문에 부분 일치하면 해당 문서를 검색 결과에 포함한다.', () => {
fileSet = new Set(['posts/doc-1.md', 'posts/doc-2.md']);
searchContentMap = {
'posts/doc-1.md': 'I like green apple pie.',
'posts/doc-2.md': 'Banana bread recipe.',
};
const tree = initTree('', 'pl');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['doc-1.md']).toBeDefined();
expect(tree.children.posts.children['doc-2.md']).toBeUndefined();
});
describe('markUsedPaths 호출 시', () => {
it('현재 경로가 비어있다면 빈 오브젝트를 반환한다', () => {
const result = markUsedPaths('');
expect(result).toStrictEqual({})
});
it('현재 경로가 비어있지 않다면 모든 서브 경로를 담은 오브젝트를 반환한다', () => {
const path = '/A/B/C';
const expectedObject = {
'/A': true,
'/A/B': true,
'/A/B/C': true
}
const result = markUsedPaths(path);
expect(result).toStrictEqual(expectedObject)
});
});
describe('renderTree 호출 시', () => {
let inputTree;
it('인자로 넘겨진 트리 노드가 컴포넌트로 렌더링된다.', () => {
const nodeTitle1 = 'file1';
const nodeTitle2 = 'file2';
inputTree = {
[nodeTitle1] : {children: {}, count: 1, isFile: true},
[nodeTitle2] : {children: {}, count: 1, isFile: true},
}
render(<MemoryRouter>{renderTree(inputTree)}</MemoryRouter>)
expect(screen.getByText(nodeTitle1)).toBeInTheDocument();
expect(screen.getByText(nodeTitle2)).toBeInTheDocument();
});
it('트리 노드가 파일 노드가 아니면 자식의 수가 타이틀에 표시된다.', () => {
const nodeKey = 'dir'
const nodeCount = 3;
inputTree = {
[nodeKey] : {
children: {},
count: nodeCount,
isFile: false
},
}
const expectedTitle = `${nodeKey} (${nodeCount})`
render(<MemoryRouter>{renderTree(inputTree)}</MemoryRouter>)
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('트리 노드가 파일 노드가 아니면 재귀적으로 렌더링된다.', () => {
inputTree = {
'dir1' : {
children: {
'dir2': {
children: {
'file2': {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false
},
'file1': {children: {}, count: 1, isFile: true},
},
count: 1,
isFile: false
},
}
render(<MemoryRouter>{renderTree(inputTree)}</MemoryRouter>)
const dir1Children = screen.getByText('dir1 (1)').nextSibling;
const file1 = screen.getByText('file1');
const dir2 = screen.getByText('dir2 (1)');
const dir2Children = dir2.nextSibling;
const file2 = screen.getByText('file2');
expect(dir1Children).toContainElement(file1);
expect(dir1Children).toContainElement(dir2);
expect(dir1Children).toContainElement(file2);
expect(dir2Children).toContainElement(file2);
expect(dir2Children).not.toContainElement(file1);
});
it('인자로 전달된 정렬 기준으로 정렬된 트리로 렌더링한다.', () => {
inputTree = {
'1' : {children: {}, count: 1, isFile: true},
'2' : {children: {}, count: 1, isFile: true},
}
render(<MemoryRouter data-testid='container'>{renderTree(inputTree, '', ([k1], [k2]) => {
return k2.localeCompare(k1);
})}</MemoryRouter>)
const el1 = screen.getByText('1');
const el2 = screen.getByText('2');
const el1Parent = el1.parentElement;
const el2Parent = el2.parentElement;
const ancestor = el1Parent.parentElement;
expect(ancestor.children[1]).toBe(el2Parent)
expect(ancestor.children[2]).toBe(el1Parent)
});
});
describe('markUsedPaths 호출 시', () => {
it('현재 경로가 비어있다면 빈 오브젝트를 반환한다', () => {
const result = markUsedPaths('');
expect(result).toStrictEqual({})
});
it('현재 경로가 비어있지 않다면 모든 서브 경로를 담은 오브젝트를 반환한다', () => {
const path = '/A/B/C';
const expectedObject = {
'/A': true,
'/A/B': true,
'/A/B/C': true
}
const result = markUsedPaths(path);
expect(result).toStrictEqual(expectedObject)
});
});

View file

@ -1,33 +1,39 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
import generateHtmlFiles from "./src/generateHtml.js";
import {createFileMapToJson, createImageMapToJson, initSourceDirectory,} from "./src/utils/file/fileUtils.js";
import {viteStaticCopy} from "vite-plugin-static-copy";
export default defineConfig({
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: 'public/*',
dest: '',
}
],
}),
{
name: 'pre-build-plugin',
async buildStart() {
import {
createFileMapToJson,
createImageMapToJson,
createMarkdownSearchMapToJson,
initSourceDirectory,
} from "./src/utils/file/fileUtils.js";
import {viteStaticCopy} from "vite-plugin-static-copy";
export default defineConfig({
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: 'public/*',
dest: '',
}
],
}),
{
name: 'pre-build-plugin',
async buildStart() {
initSourceDirectory()
createFileMapToJson()
createMarkdownSearchMapToJson()
createImageMapToJson()
await generateHtmlFiles()
}
}
],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './setupTests.js',
}
})
}
],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './setupTests.js',
}
})