diff --git a/react-app-internal/public/markdown-search.json b/react-app-internal/public/markdown-search.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/react-app-internal/public/markdown-search.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/react-app-internal/src/App.jsx b/react-app-internal/src/App.jsx index 456f420..0c0dcda 100644 --- a/react-app-internal/src/App.jsx +++ b/react-app-internal/src/App.jsx @@ -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 (); - } - - return ( -
- - - - - } - > - - -
- ); -} - -export default App; \ No newline at end of file + setIsInitialized(true); + } + initialize(); + }, []); + + if (!isInitialized) { + return (); + } + + return ( +
+ + + + + } + > + + +
+ ); +} + +export default App; diff --git a/react-app-internal/src/App.test.jsx b/react-app-internal/src/App.test.jsx index 900f2d4..1e255ed 100644 --- a/react-app-internal/src/App.test.jsx +++ b/react-app-internal/src/App.test.jsx @@ -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(); + render(); await waitFor(() => { expect(initMarkdownFileMap).toHaveBeenCalled(); + expect(initMarkdownSearchMap).toHaveBeenCalled(); expect(initImageFileMap).toHaveBeenCalled(); expect(initTocMap).toHaveBeenCalled(); }); }); it('아직 초기화 되지 않았을 때는 로딩 컴포넌트가 렌더링된다.', () => { - render(); - - expect(screen.getByTestId('init-loading-screen')).toBeInTheDocument(); - }); - + render(); + + expect(screen.getByTestId('init-loading-screen')).toBeInTheDocument(); + }); + it('초기화가 완료되면 App이 렌더링 된다.', async () => { - render(); - - await waitFor(() => { - expect(screen.queryByTestId('init-loading-screen')).not.toBeInTheDocument(); - }); - }); -}); \ No newline at end of file + render(); + + await waitFor(() => { + expect(screen.queryByTestId('init-loading-screen')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/react-app-internal/src/components/TreeContainer.jsx b/react-app-internal/src/components/TreeContainer.jsx index 391c5a3..adc5466 100644 --- a/react-app-internal/src/components/TreeContainer.jsx +++ b/react-app-internal/src/components/TreeContainer.jsx @@ -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 ( - -
-
-
- {showSearchInput && ( -
- onSearchKeywordChange(event.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: '6px 8px', - }} - /> -
- )} -
-
- {shouldRenderTree ? renderTree(tree.children, '', directoryFirstFn) : null} -
-
-
-
-
- ); -}; - -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 ( + +
+
+
+ {showSearchInput && ( +
+ onSearchKeywordChange(event.target.value)} + style={{ + width: '100%', + boxSizing: 'border-box', + padding: '6px 8px', + }} + /> +
+ )} +
+
+ {shouldRenderTree ? renderTree(tree.children, '', directoryFirstFn) : null} +
+
+
+
+
+ ); +}; + +export default TreeContainer; diff --git a/react-app-internal/src/components/TreeContainer.test.jsx b/react-app-internal/src/components/TreeContainer.test.jsx index 62a2920..b99e1ba 100644 --- a/react-app-internal/src/components/TreeContainer.test.jsx +++ b/react-app-internal/src/components/TreeContainer.test.jsx @@ -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() - expect(initTree).toHaveBeenCalled(); - }); - - it('초기화한 트리 정보를 사용해 renderTree 함수가 호출된다.', () => { - tree = { - children: { - a: { children: {}} - } - } - - render() - - expect(renderTree).toHaveBeenCalled() - expect(renderTree.mock.calls[0][0]).toStrictEqual(tree.children); - }); - - it('markUsedPaths가 호출된다', () => { - render() - expect(markUsedPaths).toHaveBeenCalled(); - }); - - it('검색어가 주어지면 트리 초기화 시 검색어를 함께 전달한다.', () => { - render() - expect(initTree).toHaveBeenCalledWith(undefined, 'react'); - }); - - it('검색 탭 모드면 검색 input이 트리 컨테이너 내부 상단에 렌더링된다.', () => { - render(); - expect(screen.getByLabelText('Search query')).toBeInTheDocument(); - }); - - it('검색 input 값 변경 시 콜백이 호출된다.', () => { - const onSearchKeywordChange = vi.fn(); - render( - - - - ); - - fireEvent.change(screen.getByLabelText('Search query'), {target: {value: 'react'}}); - expect(onSearchKeywordChange).toHaveBeenCalledWith('react'); - }); - - it('검색 탭에서 검색어가 비어있으면 트리를 렌더링하지 않는다.', () => { - render(); - expect(renderTree).not.toHaveBeenCalled(); - }); -}); + }) + + afterEach(() => { + vi.clearAllMocks(); + }) + + it('트리 초기화 로직이 호출된다.', () => { + render() + expect(initTree).toHaveBeenCalled(); + }); + + it('초기화한 트리 정보를 사용해 renderTree 함수가 호출된다.', () => { + tree = { + children: { + a: { children: {}} + } + } + + render() + + expect(renderTree).toHaveBeenCalled() + expect(renderTree.mock.calls[0][0]).toStrictEqual(tree.children); + }); + + it('markUsedPaths가 호출된다', () => { + render() + expect(markUsedPaths).toHaveBeenCalled(); + }); + + it('검색어가 주어지면 트리 초기화 시 검색어를 함께 전달한다.', () => { + render() + expect(initTree).toHaveBeenCalledWith(undefined, 'react'); + }); + + it('검색 탭 모드면 검색 input이 트리 컨테이너 내부 상단에 렌더링된다.', () => { + render(); + expect(screen.getByLabelText('Search query')).toBeInTheDocument(); + }); + + it('검색 input 값 변경 시 콜백이 호출된다.', () => { + const onSearchKeywordChange = vi.fn(); + render( + + + + ); + + fireEvent.change(screen.getByLabelText('Search query'), {target: {value: 'react'}}); + expect(onSearchKeywordChange).toHaveBeenCalledWith('react'); + }); + + it('검색 탭에서 검색어가 비어있으면 트리를 렌더링하지 않는다.', () => { + render(); + expect(renderTree).not.toHaveBeenCalled(); + }); +}); diff --git a/react-app-internal/src/utils/file/fileUtils.js b/react-app-internal/src/utils/file/fileUtils.js index 9b89452..7cb2417 100644 --- a/react-app-internal/src/utils/file/fileUtils.js +++ b/react-app-internal/src/utils/file/fileUtils.js @@ -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 }); + } +} diff --git a/react-app-internal/src/utils/file/fileUtils.test.js b/react-app-internal/src/utils/file/fileUtils.test.js index b3c1c68..a60c0d3 100644 --- a/react-app-internal/src/utils/file/fileUtils.test.js +++ b/react-app-internal/src/utils/file/fileUtils.test.js @@ -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(''); + }); +}); diff --git a/react-app-internal/src/utils/treeUtils.jsx b/react-app-internal/src/utils/treeUtils.jsx index 1f92612..caffb88 100644 --- a/react-app-internal/src/utils/treeUtils.jsx +++ b/react-app-internal/src/utils/treeUtils.jsx @@ -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 아마 크기 조절용인듯? 크기 조절 기능은 나중에 추가*/} -
- { - sortedNodes.map(([key, value]) => { - const path = `${basePath}/${key}`; - return ( - - {!value.isFile ? ( - - {renderTree(value.children, path, compareFn)} - - ) : ( - - )} - - ); - } - ) - } - - ) -}; - -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 아마 크기 조절용인듯? 크기 조절 기능은 나중에 추가*/} +
+ { + sortedNodes.map(([key, value]) => { + const path = `${basePath}/${key}`; + return ( + + {!value.isFile ? ( + + {renderTree(value.children, path, compareFn)} + + ) : ( + + )} + + ); + } + ) + } + + ) +}; + +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 +} diff --git a/react-app-internal/src/utils/treeUtils.test.jsx b/react-app-internal/src/utils/treeUtils.test.jsx index e851b26..aa4a396 100644 --- a/react-app-internal/src/utils/treeUtils.test.jsx +++ b/react-app-internal/src/utils/treeUtils.test.jsx @@ -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}) =>
{title}
{children}
})) -}); - -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}) =>
{title}
{children}
})) +}); + +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({renderTree(inputTree)}) - 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({renderTree(inputTree)}) - 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({renderTree(inputTree)}) - 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({renderTree(inputTree, '', ([k1], [k2]) => { - return k2.localeCompare(k1); - })}) - 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({renderTree(inputTree)}) + 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({renderTree(inputTree)}) + 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({renderTree(inputTree)}) + 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({renderTree(inputTree, '', ([k1], [k2]) => { + return k2.localeCompare(k1); + })}) + 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) + }); +}); diff --git a/react-app-internal/vite.config.js b/react-app-internal/vite.config.js index c7e9efa..c88b990 100644 --- a/react-app-internal/vite.config.js +++ b/react-app-internal/vite.config.js @@ -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', + } +})