mirror of
https://github.com/barkstone2/vault-to-blog.git
synced 2026-07-22 05:21:19 +00:00
fix: restore sidebar body-content search behavior
This commit is contained in:
parent
9bce010960
commit
d62ea9d69d
10 changed files with 1175 additions and 947 deletions
1
react-app-internal/public/markdown-search.json
Normal file
1
react-app-internal/public/markdown-search.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
352
react-app-internal/src/utils/file/fileUtils.js
vendored
352
react-app-internal/src/utils/file/fileUtils.js
vendored
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue