feat: add sidebar tag-query search and tag-click integration

Support  and  in sidebar search while preserving keyword filtering. Wire metadata tag clicks to open the search tab and append normalized tag queries, and parse both multiline and whitespace-collapsed frontmatter tags so indexed content matches reliably.
This commit is contained in:
barkstone2 2026-02-11 23:47:59 +09:00 committed by barkstone2
parent 89223a9c91
commit ed029974a0
9 changed files with 1004 additions and 653 deletions

View file

@ -1,62 +1,62 @@
import useBacklinkNavigation from "../utils/hooks/useBacklinkNavigation.js";
import '../styles/content.css'
import {Helmet} from "react-helmet-async";
import LoadingScreen from "./LoadingScreen.jsx";
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import UtterancesComments from "./UtterancesComments.jsx";
import {getIndexFilePath} from "../utils/file/fileUtils.js";
export function resolveMarkdownFilePath(filePath, indexFilePath) {
if (filePath && filePath !== '') {
return filePath;
}
return indexFilePath || '';
}
function resolveTitle(filePath) {
return filePath ? filePath.split('/').pop().replace('.md', '') : 'Home';
}
function MarkdownContent() {
const {'*': filePath} = useParams();
const indexFilePath = getIndexFilePath();
const [innerHtml, setInnerHtml] = useState({});
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
setIsLoading(true);
const fetchHtml = async () => {
const resolvedFilePath = resolveMarkdownFilePath(filePath, indexFilePath);
const path = resolvedFilePath ? `/html/${resolvedFilePath.replace('.md', '.html')}` : `/default/home.html`;
const response = await fetch(`${path}`);
const content = await response.text();
const title = resolveTitle(resolvedFilePath);
const newInnerHtml = {
title: title,
content: content
};
setInnerHtml(newInnerHtml)
setIsLoading(false);
};
fetchHtml()
}, [filePath, indexFilePath]);
useBacklinkNavigation(innerHtml.content);
if (isLoading) {
return <LoadingScreen/>
}
return (
<>
<Helmet>
<title>{innerHtml.title}</title>
</Helmet>
<div className="markdown-preview-sizer markdown-preview-section"
dangerouslySetInnerHTML={{__html: innerHtml.content}}>
</div>
{ resolveMarkdownFilePath(filePath, indexFilePath) !== '' && <UtterancesComments /> }
</>
);
}
export default MarkdownContent;
import useBacklinkNavigation from "../utils/hooks/useBacklinkNavigation.js";
import '../styles/content.css'
import {Helmet} from "react-helmet-async";
import LoadingScreen from "./LoadingScreen.jsx";
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import UtterancesComments from "./UtterancesComments.jsx";
import {getIndexFilePath} from "../utils/file/fileUtils.js";
export function resolveMarkdownFilePath(filePath, indexFilePath) {
if (filePath && filePath !== '') {
return filePath;
}
return indexFilePath || '';
}
function resolveTitle(filePath) {
return filePath ? filePath.split('/').pop().replace('.md', '') : 'Home';
}
function MarkdownContent({onTagSelected = (_tagValue) => {}}) {
const {'*': filePath} = useParams();
const indexFilePath = getIndexFilePath();
const [innerHtml, setInnerHtml] = useState({});
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
setIsLoading(true);
const fetchHtml = async () => {
const resolvedFilePath = resolveMarkdownFilePath(filePath, indexFilePath);
const path = resolvedFilePath ? `/html/${resolvedFilePath.replace('.md', '.html')}` : `/default/home.html`;
const response = await fetch(`${path}`);
const content = await response.text();
const title = resolveTitle(resolvedFilePath);
const newInnerHtml = {
title: title,
content: content
};
setInnerHtml(newInnerHtml)
setIsLoading(false);
};
fetchHtml()
}, [filePath, indexFilePath]);
useBacklinkNavigation(innerHtml.content, onTagSelected);
if (isLoading) {
return <LoadingScreen/>
}
return (
<>
<Helmet>
<title>{innerHtml.title}</title>
</Helmet>
<div className="markdown-preview-sizer markdown-preview-section"
dangerouslySetInnerHTML={{__html: innerHtml.content}}>
</div>
{ resolveMarkdownFilePath(filePath, indexFilePath) !== '' && <UtterancesComments /> }
</>
);
}
export default MarkdownContent;

View file

@ -1,14 +1,34 @@
import {describe, expect, it} from "vitest";
import {resolveEffectiveMarkdownPath, resolveTocHeaders} from "./WorkspaceContainer";
import {appendTagQuery, createTagQuery, resolveEffectiveMarkdownPath, resolveTocHeaders} from "./WorkspaceContainer";
describe('WorkspaceContainer helpers', () => {
it('홈 경로이고 index 파일이 있으면 index 파일 경로를 사용한다.', () => {
const resolved = resolveEffectiveMarkdownPath('', 'index.md');
expect(resolved).toBe('index.md');
});
it('홈 경로이고 index 파일이 있으면 index 파일 경로를 사용한다.', () => {
const resolved = resolveEffectiveMarkdownPath('', 'index.md');
expect(resolved).toBe('index.md');
});
it('toc 맵에 키가 없으면 빈 배열을 반환한다.', () => {
const headers = resolveTocHeaders({}, '', 'index.md');
expect(headers).toStrictEqual([]);
});
it('태그 검색어를 생성할 때 # 접두사를 제거하고 소문자로 정규화한다.', () => {
const query = createTagQuery('#React');
expect(query).toBe('tag:react');
});
it('기존 검색어가 비어있으면 태그 검색어만 반환한다.', () => {
const query = appendTagQuery('', 'React');
expect(query).toBe('tag:react');
});
it('기존 검색어가 있으면 태그 검색어를 뒤에 추가한다.', () => {
const query = appendTagQuery('state management', 'React');
expect(query).toBe('state management tag:react');
});
it('동일한 태그 검색어가 이미 있으면 중복으로 추가하지 않는다.', () => {
const query = appendTagQuery('state tag:react', '#React');
expect(query).toBe('state tag:react');
});
});

View file

@ -1,239 +1,283 @@
import {MouseEvent as ReactMouseEvent, ReactNode, useCallback, useEffect, useState} from "react";
import {WorkspaceSplitContainer} from "./WorkspaceSplitContainer";
import TreeContainer from "../components/TreeContainer";
import {WorkspaceTabs} from "../components/tab-header/WorkspaceTabs";
import {WorkspaceTabHeaderContainer} from "./WorkspaceTabHeaderContainer";
import FileExplorerTabHeader from "../components/sidebar/FileExplorerTabHeader";
import SearchTabHeader from "../components/sidebar/SearchTabHeader";
import HomeTabHeader from "../components/sidebar/HomeTabHeader";
import {WorkspaceTabHeader} from "../components/tab-header/WorkspaceTabHeader";
import {LucideList} from "lucide-react";
import {useParams} from "react-router-dom";
import {getIndexFilePath, getTocMap} from "../utils/file/fileUtils";
import {TocHeaders} from "../components/tab-header/TocHeaders";
import {SidebarRightToggleSvg} from "../components/svg/SidebarRightToggleSvg";
import {SidebarToggleButton} from "../components/SidebarToggleButton";
import {SidebarLeftToggleSvg} from "../components/svg/SidebarLeftToggleSvg";
import {HeaderOfToc} from "../types/HeaderOfToc";
export function resolveEffectiveMarkdownPath(filePath: string | undefined, indexFilePath: string): string {
if (filePath && filePath !== '') {
return filePath;
}
return indexFilePath || '';
}
import {cloneElement, isValidElement, MouseEvent as ReactMouseEvent, ReactElement, ReactNode, useCallback, useEffect, useState} from "react";
import {WorkspaceSplitContainer} from "./WorkspaceSplitContainer";
import TreeContainer from "../components/TreeContainer";
import {WorkspaceTabs} from "../components/tab-header/WorkspaceTabs";
import {WorkspaceTabHeaderContainer} from "./WorkspaceTabHeaderContainer";
import FileExplorerTabHeader from "../components/sidebar/FileExplorerTabHeader";
import SearchTabHeader from "../components/sidebar/SearchTabHeader";
import HomeTabHeader from "../components/sidebar/HomeTabHeader";
import {WorkspaceTabHeader} from "../components/tab-header/WorkspaceTabHeader";
import {LucideList} from "lucide-react";
import {useParams} from "react-router-dom";
import {getIndexFilePath, getTocMap} from "../utils/file/fileUtils";
import {TocHeaders} from "../components/tab-header/TocHeaders";
import {SidebarRightToggleSvg} from "../components/svg/SidebarRightToggleSvg";
import {SidebarToggleButton} from "../components/SidebarToggleButton";
import {SidebarLeftToggleSvg} from "../components/svg/SidebarLeftToggleSvg";
import {HeaderOfToc} from "../types/HeaderOfToc";
export function resolveEffectiveMarkdownPath(filePath: string | undefined, indexFilePath: string): string {
if (filePath && filePath !== '') {
return filePath;
}
return indexFilePath || '';
}
export function resolveTocHeaders(
tocMap: {[key: string]: HeaderOfToc},
filePath: string | undefined,
indexFilePath: string,
): HeaderOfToc[] {
const resolvedPath = resolveEffectiveMarkdownPath(filePath, indexFilePath);
const tocOfFile = tocMap[resolvedPath];
const resolvedPath = resolveEffectiveMarkdownPath(filePath, indexFilePath);
const tocOfFile = tocMap[resolvedPath];
return tocOfFile?.children ?? [];
}
const leftSideDockOpenedClassName = 'is-left-sidedock-open';
const rightSideDockOpenedClassName = 'is-right-sidedock-open';
const defaultLeftSideDockWidth = 300;
const minLeftSideDockWidth = 220;
const maxLeftSideDockWidth = 560;
export function normalizeTagKeyword(tagValue: string): string {
return tagValue
.normalize('NFC')
.trim()
.replace(/^#/, '')
.toLowerCase();
}
export function createTagQuery(tagValue: string): string {
const normalizedTag = normalizeTagKeyword(tagValue);
return normalizedTag ? `tag:${normalizedTag}` : '';
}
export function appendTagQuery(searchKeyword: string, tagValue: string): string {
const tagQuery = createTagQuery(tagValue);
if (!tagQuery) {
return searchKeyword;
}
const normalizedKeyword = (searchKeyword || '').trim();
if (!normalizedKeyword) {
return tagQuery;
}
const hasTagQuery = normalizedKeyword.split(/\s+/).some((token) => token.toLowerCase() === tagQuery.toLowerCase());
if (hasTagQuery) {
return normalizedKeyword;
}
return `${normalizedKeyword} ${tagQuery}`;
}
const leftSideDockOpenedClassName = 'is-left-sidedock-open';
const rightSideDockOpenedClassName = 'is-right-sidedock-open';
const defaultLeftSideDockWidth = 300;
const minLeftSideDockWidth = 220;
const maxLeftSideDockWidth = 560;
export function WorkspaceContainer({children}: {children: ReactNode}) {
const {'*': filePath } = useParams();
const tocMap = getTocMap() as {[key: string]: HeaderOfToc};
const tocHeaders = resolveTocHeaders(tocMap, filePath, getIndexFilePath());
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 900px)');
setIsMobile(mediaQuery.matches);
const handleChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const [isLeftSideDockOpened, setIsLeftSideDockOpened] = useState(!isMobile);
const [isRightSideDockOpened, setIsRightSideDockOpened] = useState(!isMobile);
const [leftSideDockWidth, setLeftSideDockWidth] = useState(defaultLeftSideDockWidth);
const [leftSidebarTab, setLeftSidebarTab] = useState<'file-explorer' | 'search'>('file-explorer');
const [searchKeyword, setSearchKeyword] = useState('');
const {'*': filePath } = useParams();
const tocMap = getTocMap() as {[key: string]: HeaderOfToc};
const tocHeaders = resolveTocHeaders(tocMap, filePath, getIndexFilePath());
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 900px)');
setIsMobile(mediaQuery.matches);
const handleChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const [isLeftSideDockOpened, setIsLeftSideDockOpened] = useState(!isMobile);
const [isRightSideDockOpened, setIsRightSideDockOpened] = useState(!isMobile);
const [leftSideDockWidth, setLeftSideDockWidth] = useState(defaultLeftSideDockWidth);
const [leftSidebarTab, setLeftSidebarTab] = useState<'file-explorer' | 'search'>('file-explorer');
const [searchKeyword, setSearchKeyword] = useState('');
const hasActiveSearch = searchKeyword.trim().length > 0;
useEffect(() => {
setIsLeftSideDockOpened(!isMobile)
setIsRightSideDockOpened(!isMobile)
const handleTagSelected = useCallback((tagValue: string) => {
setSearchKeyword((prevKeyword) => appendTagQuery(prevKeyword, tagValue));
setLeftSidebarTab('search');
if (isMobile) {
setIsLeftSideDockOpened(true);
}
}, [isMobile]);
let workspaceClassName = 'workspace';
if (isLeftSideDockOpened) {
workspaceClassName += ' ' + leftSideDockOpenedClassName;
}
if (isRightSideDockOpened) {
workspaceClassName += ' ' + rightSideDockOpenedClassName;
}
const handleLeftSidebarResizeMouseDown = useCallback((event: ReactMouseEvent<HTMLHRElement>) => {
if (isMobile || !isLeftSideDockOpened) {
return;
}
event.preventDefault();
const startX = event.clientX;
const startWidth = leftSideDockWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const nextWidth = Math.min(
maxLeftSideDockWidth,
Math.max(minLeftSideDockWidth, startWidth + (moveEvent.clientX - startX)),
);
setLeftSideDockWidth(nextWidth);
};
const handleMouseUp = () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}, [isLeftSideDockOpened, isMobile, leftSideDockWidth]);
return (
<div className={workspaceClassName}>
<WorkspaceSplitContainer modOrientation={'mod-horizontal'}
isSideDock={true}
isSideDockCollapse={!isLeftSideDockOpened}
width={leftSideDockWidth}
onResizeMouseDown={handleLeftSidebarResizeMouseDown}
modSplitDirection={'mod-left-split'}>
<WorkspaceTabs spaceDirection={'mod-top-left-space'}>
<WorkspaceTabHeaderContainer
hasLeftDockToggleButtonOnRight={isLeftSideDockOpened}
leftToggleButtonIcon={
<SidebarToggleButton
direction={'mod-left'}
tooltipPosition={'right'}
toggleIcon={<SidebarLeftToggleSvg/>}
onClick={() => setIsLeftSideDockOpened(!isLeftSideDockOpened)}
/>
}
>
<HomeTabHeader/>
<FileExplorerTabHeader
isActive={leftSidebarTab === 'file-explorer'}
onSelectTab={() => setLeftSidebarTab('file-explorer')}
/>
<SearchTabHeader
isActive={leftSidebarTab === 'search'}
onSelectTab={() => setLeftSidebarTab('search')}
/>
{/* TODO left sidebar에 다른 탭 추가될 때 주석 해제 */}
{/*<OutlineTabHeader title=""/>*/}
{/*<TagsTabHeader/>*/}
</WorkspaceTabHeaderContainer>
<div className="workspace-tab-container">
<div style={{display: leftSidebarTab === 'file-explorer' ? 'block' : 'none'}}>
<TreeContainer
datatype="file-explorer"
/>
</div>
<div style={{display: leftSidebarTab === 'search' ? 'block' : 'none'}}>
<TreeContainer
datatype="search"
searchKeyword={searchKeyword}
forceExpandDirectories={hasActiveSearch}
showSearchInput={true}
onSearchKeywordChange={setSearchKeyword}
/>
</div>
</div>
</WorkspaceTabs>
</WorkspaceSplitContainer>
<WorkspaceSplitContainer modOrientation={"mod-vertical"} isSideDock={false}>
<WorkspaceTabs>
<WorkspaceTabHeaderContainer
hasLeftDockToggleButtonOnLeft={!isLeftSideDockOpened}
hasRightDockToggleButtonOnRight={!isRightSideDockOpened}
leftToggleButtonIcon={
<SidebarToggleButton
direction={'mod-left'}
tooltipPosition={'right'}
toggleIcon={<SidebarLeftToggleSvg/>}
onClick={() => setIsLeftSideDockOpened(!isLeftSideDockOpened)}
/>
}
rightToggleButtonIcon={
<SidebarToggleButton
direction={'mod-right'}
tooltipPosition={'left'}
toggleIcon={<SidebarRightToggleSvg/>}
onClick={() => setIsRightSideDockOpened(!isRightSideDockOpened)}
/>
}
/>
<div className="workspace-tab-container">
{/* TODO 탭 전환 기능 추가 시 leaf 단위로 처리가 필요 */}
<div className="workspace-leaf">
<hr className="workspace-leaf-resize-handle"/>
<div className="workspace-leaf-content" data-type="markdown" data-mode="preview">
{/* TODO 요청 path 기준으로 렌더링 가능 */}
<div className="view-header">
</div>
<div className="view-content">
<div className="markdown-reading-view" style={{width: '100%', height: '100%'}}>
<div
className="markdown-preview-view markdown-rendered node-insert-event is-readable-line-width allow-fold-headings show-indentation-guide allow-fold-lists show-properties"
tabIndex={-1} style={{tabSize: 4}}>
{children}
</div>
</div>
</div>
</div>
</div>
</div>
</WorkspaceTabs>
</WorkspaceSplitContainer>
<WorkspaceSplitContainer modOrientation={'mod-horizontal'} isSideDock={true}
isSideDockCollapse={!isRightSideDockOpened}
modSplitDirection={'mod-right-split'}>
<WorkspaceTabs spaceDirection={'mod-top-left-space'}>
<WorkspaceTabHeaderContainer
hasRightDockToggleButtonOnLeft={isRightSideDockOpened}
rightToggleButtonIcon={
<SidebarToggleButton
direction={'mod-right'}
tooltipPosition={'left'}
toggleIcon={<SidebarRightToggleSvg/>}
onClick={() => setIsRightSideDockOpened(!isRightSideDockOpened)}
/>
}
>
<WorkspaceTabHeader
title={`Outline of {pathname}`}
dataType={'outline'}
innerIcon={<LucideList/>}
activeClass={'is-active mod-active'}
/>
</WorkspaceTabHeaderContainer>
<div className="workspace-tab-container">
<div className="workspace-leaf">
<hr className="workspace-leaf-resize-handle"/>
<div className="workspace-leaf-content" data-type={'outline'}>
<div className="view-content node-insert-event" style={{position: 'relative'}}>
<TocHeaders headers={tocHeaders}/>
</div>
</div>
</div>
</div>
</WorkspaceTabs>
</WorkspaceSplitContainer>
</div>
)
}
const renderedChildren = isValidElement(children)
? cloneElement(children as ReactElement<{onTagSelected?: (tagValue: string) => void}>, {onTagSelected: handleTagSelected})
: children;
useEffect(() => {
setIsLeftSideDockOpened(!isMobile)
setIsRightSideDockOpened(!isMobile)
}, [isMobile]);
let workspaceClassName = 'workspace';
if (isLeftSideDockOpened) {
workspaceClassName += ' ' + leftSideDockOpenedClassName;
}
if (isRightSideDockOpened) {
workspaceClassName += ' ' + rightSideDockOpenedClassName;
}
const handleLeftSidebarResizeMouseDown = useCallback((event: ReactMouseEvent<HTMLHRElement>) => {
if (isMobile || !isLeftSideDockOpened) {
return;
}
event.preventDefault();
const startX = event.clientX;
const startWidth = leftSideDockWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const nextWidth = Math.min(
maxLeftSideDockWidth,
Math.max(minLeftSideDockWidth, startWidth + (moveEvent.clientX - startX)),
);
setLeftSideDockWidth(nextWidth);
};
const handleMouseUp = () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}, [isLeftSideDockOpened, isMobile, leftSideDockWidth]);
return (
<div className={workspaceClassName}>
<WorkspaceSplitContainer modOrientation={'mod-horizontal'}
isSideDock={true}
isSideDockCollapse={!isLeftSideDockOpened}
width={leftSideDockWidth}
onResizeMouseDown={handleLeftSidebarResizeMouseDown}
modSplitDirection={'mod-left-split'}>
<WorkspaceTabs spaceDirection={'mod-top-left-space'}>
<WorkspaceTabHeaderContainer
hasLeftDockToggleButtonOnRight={isLeftSideDockOpened}
leftToggleButtonIcon={
<SidebarToggleButton
direction={'mod-left'}
tooltipPosition={'right'}
toggleIcon={<SidebarLeftToggleSvg/>}
onClick={() => setIsLeftSideDockOpened(!isLeftSideDockOpened)}
/>
}
>
<HomeTabHeader/>
<FileExplorerTabHeader
isActive={leftSidebarTab === 'file-explorer'}
onSelectTab={() => setLeftSidebarTab('file-explorer')}
/>
<SearchTabHeader
isActive={leftSidebarTab === 'search'}
onSelectTab={() => setLeftSidebarTab('search')}
/>
{/* TODO left sidebar에 다른 탭 추가될 때 주석 해제 */}
{/*<OutlineTabHeader title=""/>*/}
{/*<TagsTabHeader/>*/}
</WorkspaceTabHeaderContainer>
<div className="workspace-tab-container">
<div style={{display: leftSidebarTab === 'file-explorer' ? 'block' : 'none'}}>
<TreeContainer
datatype="file-explorer"
/>
</div>
<div style={{display: leftSidebarTab === 'search' ? 'block' : 'none'}}>
<TreeContainer
datatype="search"
searchKeyword={searchKeyword}
forceExpandDirectories={hasActiveSearch}
showSearchInput={true}
onSearchKeywordChange={setSearchKeyword}
/>
</div>
</div>
</WorkspaceTabs>
</WorkspaceSplitContainer>
<WorkspaceSplitContainer modOrientation={"mod-vertical"} isSideDock={false}>
<WorkspaceTabs>
<WorkspaceTabHeaderContainer
hasLeftDockToggleButtonOnLeft={!isLeftSideDockOpened}
hasRightDockToggleButtonOnRight={!isRightSideDockOpened}
leftToggleButtonIcon={
<SidebarToggleButton
direction={'mod-left'}
tooltipPosition={'right'}
toggleIcon={<SidebarLeftToggleSvg/>}
onClick={() => setIsLeftSideDockOpened(!isLeftSideDockOpened)}
/>
}
rightToggleButtonIcon={
<SidebarToggleButton
direction={'mod-right'}
tooltipPosition={'left'}
toggleIcon={<SidebarRightToggleSvg/>}
onClick={() => setIsRightSideDockOpened(!isRightSideDockOpened)}
/>
}
/>
<div className="workspace-tab-container">
{/* TODO 탭 전환 기능 추가 시 leaf 단위로 처리가 필요 */}
<div className="workspace-leaf">
<hr className="workspace-leaf-resize-handle"/>
<div className="workspace-leaf-content" data-type="markdown" data-mode="preview">
{/* TODO 요청 path 기준으로 렌더링 가능 */}
<div className="view-header">
</div>
<div className="view-content">
<div className="markdown-reading-view" style={{width: '100%', height: '100%'}}>
<div
className="markdown-preview-view markdown-rendered node-insert-event is-readable-line-width allow-fold-headings show-indentation-guide allow-fold-lists show-properties"
tabIndex={-1} style={{tabSize: 4}}>
{renderedChildren}
</div>
</div>
</div>
</div>
</div>
</div>
</WorkspaceTabs>
</WorkspaceSplitContainer>
<WorkspaceSplitContainer modOrientation={'mod-horizontal'} isSideDock={true}
isSideDockCollapse={!isRightSideDockOpened}
modSplitDirection={'mod-right-split'}>
<WorkspaceTabs spaceDirection={'mod-top-left-space'}>
<WorkspaceTabHeaderContainer
hasRightDockToggleButtonOnLeft={isRightSideDockOpened}
rightToggleButtonIcon={
<SidebarToggleButton
direction={'mod-right'}
tooltipPosition={'left'}
toggleIcon={<SidebarRightToggleSvg/>}
onClick={() => setIsRightSideDockOpened(!isRightSideDockOpened)}
/>
}
>
<WorkspaceTabHeader
title={`Outline of {pathname}`}
dataType={'outline'}
innerIcon={<LucideList/>}
activeClass={'is-active mod-active'}
/>
</WorkspaceTabHeaderContainer>
<div className="workspace-tab-container">
<div className="workspace-leaf">
<hr className="workspace-leaf-resize-handle"/>
<div className="workspace-leaf-content" data-type={'outline'}>
<div className="view-content node-insert-event" style={{position: 'relative'}}>
<TocHeaders headers={tocHeaders}/>
</div>
</div>
</div>
</div>
</WorkspaceTabs>
</WorkspaceSplitContainer>
</div>
)
}

View file

@ -1,60 +1,75 @@
import {fireEvent, renderHook} from "@testing-library/react";
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
import useBacklinkNavigation from "./useBacklinkNavigation.js";
import {MemoryRouter, useNavigate} from "react-router-dom";
const navigate = vi.fn();
beforeAll(() => {
vi.mock('react-router-dom', async (importOriginal) => {
return {
...(await importOriginal()),
useNavigate: vi.fn(),
}
});
useNavigate.mockReturnValue(navigate);
})
afterAll(() => {
vi.clearAllMocks();
})
import {fireEvent, renderHook} from "@testing-library/react";
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
import useBacklinkNavigation from "./useBacklinkNavigation.js";
import {MemoryRouter, useNavigate} from "react-router-dom";
const navigate = vi.fn();
beforeAll(() => {
vi.mock('react-router-dom', async (importOriginal) => {
return {
...(await importOriginal()),
useNavigate: vi.fn(),
}
});
useNavigate.mockReturnValue(navigate);
})
afterAll(() => {
vi.clearAllMocks();
})
describe('백링크 네비게이션 훅 동작 시', () => {
const setup = (content) => {
const setup = (content, onTagSelected = () => {}) => {
const wrapper = ({children}) => (
<MemoryRouter>
{children}
</MemoryRouter>
);
return renderHook(() => useBacklinkNavigation(content), {wrapper});
return renderHook(() => useBacklinkNavigation(content, onTagSelected), {wrapper});
};
it('backlink 클래스 클릭 시 목적지로 이동하고 스크롤이 상단으로 이동한다.', () => {
const href = "/test-path";
document.body.innerHTML = `<div class="markdown-preview-view"><a href="${href}" class="internal-link">Test Link</a></div>`;
const content = ''
setup(content)
const link = document.querySelector('.internal-link');
const markdownView = document.querySelector('.markdown-preview-view');
markdownView.scrollTo = vi.fn();
fireEvent.click(link);
expect(markdownView.scrollTo).toHaveBeenCalledWith(0, 0);
expect(navigate).toHaveBeenCalledWith(href);
});
it('backlink 클래스 클릭 시 목적지로 이동하고 스크롤이 상단으로 이동한다.', () => {
const href = "/test-path";
document.body.innerHTML = `<div class="markdown-preview-view"><a href="${href}" class="internal-link">Test Link</a></div>`;
const content = ''
setup(content)
const link = document.querySelector('.internal-link');
const markdownView = document.querySelector('.markdown-preview-view');
markdownView.scrollTo = vi.fn();
fireEvent.click(link);
expect(markdownView.scrollTo).toHaveBeenCalledWith(0, 0);
expect(navigate).toHaveBeenCalledWith(href);
});
it('훅 클린업 동작 시 이벤트 리스너가 제거된다.', () => {
const href = "/test-path";
document.body.innerHTML = `<a href="${href}" class="internal-link">Test Link</a>`;
const content = ''
const { unmount } = setup(content);
const link = document.querySelector('.internal-link');
const removeEventListenerSpy = vi.spyOn(link, 'removeEventListener');
unmount();
const href = "/test-path";
document.body.innerHTML = `<a href="${href}" class="internal-link">Test Link</a>`;
const content = ''
const { unmount } = setup(content);
const link = document.querySelector('.internal-link');
const removeEventListenerSpy = vi.spyOn(link, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
});
});
it('태그 필을 클릭하면 태그 선택 핸들러가 호출된다.', () => {
const onTagSelected = vi.fn();
const tagValue = 'React';
document.body.innerHTML =
`<div class="metadata-property" data-property-type="tags">` +
`<div class="multi-select-pill" data-tag-value="${tagValue}"><div class="multi-select-pill-content"><span>${tagValue}</span></div></div>` +
`</div>`;
setup('', onTagSelected);
const tag = document.querySelector('.multi-select-pill');
fireEvent.click(tag);
expect(onTagSelected).toHaveBeenCalledWith(tagValue);
});
});

View file

@ -1,25 +1,50 @@
import {useNavigate} from "react-router-dom";
import {useCallback, useEffect} from "react";
function useBacklinkNavigation(content) {
function useBacklinkNavigation(content, onTagSelected = () => {}) {
const navigate = useNavigate();
const handleNavigate = useCallback((e) => {
e.preventDefault();
const navPath = e.target.getAttribute('href')
const link = e.target.closest('.internal-link');
const navPath = link?.getAttribute('href');
if (!navPath) {
return;
}
navigate(navPath)
document.querySelector('.markdown-preview-view').scrollTo(0, 0);
}, [navigate]);
const handleTagSearch = useCallback((e) => {
e.preventDefault();
const tagElement = e.currentTarget;
const tagValue = (tagElement.getAttribute('data-tag-value') || tagElement.textContent || '').trim();
if (!tagValue) {
return;
}
onTagSelected(tagValue);
}, [onTagSelected]);
useEffect(() => {
const backlinks = document.querySelectorAll('.internal-link');
backlinks.forEach(link => {
link.addEventListener('click', handleNavigate);
});
const tags = document.querySelectorAll('.metadata-property[data-property-type="tags"] .multi-select-pill');
tags.forEach((tag) => {
tag.addEventListener('click', handleTagSearch);
});
return () => {
backlinks.forEach(link => {
link.removeEventListener('click', handleNavigate);
})
tags.forEach((tag) => {
tag.removeEventListener('click', handleTagSearch);
});
};
}, [handleNavigate, content]);
}, [handleNavigate, handleTagSearch, content]);
}
export default useBacklinkNavigation;
export default useBacklinkNavigation;

View file

@ -1,96 +1,96 @@
import {loadPropertySVGSync} from "./svg/svgUtils.js";
import {addNewTag} from "./remarkUtils.js";
import fs from "fs";
export function addPropertyKey(nodes, key, type) {
nodes.push({
type: 'html',
value:
`<div class="metadata-property-key">
<div class="metadata-property-icon" aria-disabled="false">${loadPropertySVGSync(type)}</div>
<input class="metadata-property-key-input" tabindex="-1" type="text" aria-label="${key}" value="${key}"/>
</div>`
});
}
export function addPropertyValue(nodes, value, type) {
addNewTag(nodes, `<div class="metadata-property-value" tabindex="-1">`, '</div>',
() => {
nodes.push({
type: 'html',
value: createValueTag(type, value)
})
});
}
function createValueTag(type, value) {
let valueTag;
if (type === 'number') {
valueTag = `<input class="metadata-input metadata-input-number" tabindex="-1" type="number" placeholder="Empty" value="${value}">`
} else if (type === 'datetime') {
valueTag = `<input class="metadata-input metadata-input-text mod-datetime" tabindex="-1" type="datetime-local" placeholder="Empty" value="${value}">`
} else if (type === 'date') {
valueTag = `<input class="metadata-input metadata-input-text mod-date is-empty" tabindex="-1" type="date" placeholder="Empty" value="${value}">`
} else if (type === 'checkbox') {
valueTag = `<input class="metadata-input-checkbox" tabindex="-1" type="checkbox" data-indeterminate="false" ${value ? 'checked' : ''} onclick="return false;">`
} else {
valueTag = `<div class="metadata-input-longtext mod-truncate" tabindex="-1" placeholder="Empty">${value}</div>`
}
return valueTag;
}
export function parseKeyAndValue(property) {
let key = '';
let value;
property = property = property.trimStart();
if (property.startsWith('-')) {
value = property.slice(2);
} else {
const split = property.split(':')
key = split[0]
value = split.slice(1).join(':').trimStart();
}
return {key, value};
}
export function addMultiPropertyValue(nodes, type, properties, index) {
addNewTag(nodes,
`<div class="metadata-property-value"><div class="multi-select-container">`,
`</div></div>`,
() => {
while (index + 1 < properties.length) {
const nextProperty = properties[index + 1];
const {key, value} = parseKeyAndValue(nextProperty)
if (key) break;
nodes.push({
type: 'html',
value: createMultiValueTag(type, value)
})
index++;
}
}
);
return index;
}
import {loadPropertySVGSync} from "./svg/svgUtils.js";
import {addNewTag} from "./remarkUtils.js";
import fs from "fs";
export function addPropertyKey(nodes, key, type) {
nodes.push({
type: 'html',
value:
`<div class="metadata-property-key">
<div class="metadata-property-icon" aria-disabled="false">${loadPropertySVGSync(type)}</div>
<input class="metadata-property-key-input" tabindex="-1" type="text" aria-label="${key}" value="${key}"/>
</div>`
});
}
export function addPropertyValue(nodes, value, type) {
addNewTag(nodes, `<div class="metadata-property-value" tabindex="-1">`, '</div>',
() => {
nodes.push({
type: 'html',
value: createValueTag(type, value)
})
});
}
function createValueTag(type, value) {
let valueTag;
if (type === 'number') {
valueTag = `<input class="metadata-input metadata-input-number" tabindex="-1" type="number" placeholder="Empty" value="${value}">`
} else if (type === 'datetime') {
valueTag = `<input class="metadata-input metadata-input-text mod-datetime" tabindex="-1" type="datetime-local" placeholder="Empty" value="${value}">`
} else if (type === 'date') {
valueTag = `<input class="metadata-input metadata-input-text mod-date is-empty" tabindex="-1" type="date" placeholder="Empty" value="${value}">`
} else if (type === 'checkbox') {
valueTag = `<input class="metadata-input-checkbox" tabindex="-1" type="checkbox" data-indeterminate="false" ${value ? 'checked' : ''} onclick="return false;">`
} else {
valueTag = `<div class="metadata-input-longtext mod-truncate" tabindex="-1" placeholder="Empty">${value}</div>`
}
return valueTag;
}
export function parseKeyAndValue(property) {
let key = '';
let value;
property = property = property.trimStart();
if (property.startsWith('-')) {
value = property.slice(2);
} else {
const split = property.split(':')
key = split[0]
value = split.slice(1).join(':').trimStart();
}
return {key, value};
}
export function addMultiPropertyValue(nodes, type, properties, index) {
addNewTag(nodes,
`<div class="metadata-property-value"><div class="multi-select-container">`,
`</div></div>`,
() => {
while (index + 1 < properties.length) {
const nextProperty = properties[index + 1];
const {key, value} = parseKeyAndValue(nextProperty)
if (key) break;
nodes.push({
type: 'html',
value: createMultiValueTag(type, value)
})
index++;
}
}
);
return index;
}
function createMultiValueTag(type, value) {
let valueTag;
if (type === 'multitext') {
valueTag = `<div class="multi-select-pill"><div class="multi-select-pill-content">${value}</div></div>`;
} else {
valueTag = `<div class="multi-select-pill" tabindex="0"><div class="multi-select-pill-content"><span>${value}</span></div></div>`
valueTag = `<div class="multi-select-pill metadata-property-tag" tabindex="0" data-tag-value="${value}"><div class="multi-select-pill-content"><span>${value}</span></div></div>`
}
return valueTag;
}
const typesPath = 'public/types.json';
export function getPropertyType(key) {
const normalizedKey = key.trim().toLowerCase();
if (normalizedKey === 'tags' || normalizedKey === 'tag') {
return 'tags';
}
const types = fs.existsSync(typesPath) ? fs.readFileSync(typesPath, 'utf-8') : '{}';
const typesMap = JSON.parse(types).types ?? {};
return typesMap[[key]] ?? 'text';
}
const typesPath = 'public/types.json';
export function getPropertyType(key) {
const normalizedKey = key.trim().toLowerCase();
if (normalizedKey === 'tags' || normalizedKey === 'tag') {
return 'tags';
}
const types = fs.existsSync(typesPath) ? fs.readFileSync(typesPath, 'utf-8') : '{}';
const typesMap = JSON.parse(types).types ?? {};
return typesMap[[key]] ?? 'text';
}

View file

@ -1,215 +1,215 @@
import {
addMultiPropertyValue,
addPropertyValue,
getPropertyType,
parseKeyAndValue
} from "./propertyRemarkUtils.js";
import {describe, vi} from "vitest";
import fs from "fs";
function wrapTagByObject(tag) {
return {
type: 'html',
value: tag
}
}
describe('parseKeyAndValue 호출 시', () => {
let property;
it(`프로퍼티가 "-" 문자로 시작하지 않으면 ":" 문자를 기준으로 키와 값으로 분리된다.`, () => {
const keyInput = 'key'
const valueInput = 'value';
property = `${keyInput}: ${valueInput}`;
const {key, value} = parseKeyAndValue(property);
expect(key).toBe(keyInput)
expect(value).toBe(valueInput)
});
it('프로퍼티가 "-" 문자로 시작하면, 값만 반환된다.', () => {
const valueInput = 'value';
property = ` - ${valueInput}`;
const {key, value} = parseKeyAndValue(property);
expect(key).toBe('')
expect(value).toBe(valueInput)
});
});
describe('addPropertyValue 호출 시', () => {
let value = 'default value';
let expectedTag;
const nodes = [];
let spy;
beforeAll(() => {
spy = vi.spyOn(nodes, 'push');
})
afterAll(() => {
vi.clearAllMocks();
})
it('number 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input metadata-input-number" tabindex="-1" type="number" placeholder="Empty" value="${value}">`
addPropertyValue(nodes, value, 'number');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('datetime 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input metadata-input-text mod-datetime" tabindex="-1" type="datetime-local" placeholder="Empty" value="${value}">`
addPropertyValue(nodes, value, 'datetime');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('date 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input metadata-input-text mod-date is-empty" tabindex="-1" type="date" placeholder="Empty" value="${value}">`
addPropertyValue(nodes, value, 'date');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('checkbox 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input-checkbox" tabindex="-1" type="checkbox" data-indeterminate="false" ${value ? 'checked' : ''} onclick="return false;">`
addPropertyValue(nodes, value, 'checkbox');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('text 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<div class="metadata-input-longtext mod-truncate" tabindex="-1" placeholder="Empty">${value}</div>`
addPropertyValue(nodes, value, 'text');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('타입 정보가 올바르지 않으면 text 타입의 태그가 추가된다.', () => {
expectedTag = `<div class="metadata-input-longtext mod-truncate" tabindex="-1" placeholder="Empty">${value}</div>`
addPropertyValue(nodes, value, 'unknown');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
});
describe('getPropertyType 호출 시', () => {
it('types.json 파일이 없어도 tags 키는 tags 타입이 반환된다.', () => {
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return false;
})
const result = getPropertyType('tags');
expect(result).toBe('tags');
vi.clearAllMocks();
});
it('types.json 파일이 존재하지 않는 경우 text 타입이 반환된다.', () => {
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return false;
})
const result = getPropertyType('aa');
expect(result).toBe('text');
vi.clearAllMocks();
});
it('맵에 정보가 있는 key인 경우 기록된 타입이 반환된다.', () => {
const key = 'key';
const type = 'date';
vi.spyOn(JSON, 'parse').mockImplementation(() => {
return {
types: {
[key]: `${type}`
}
}
})
const result = getPropertyType(key);
expect(result).toBe(type);
vi.clearAllMocks()
});
it('맵에 정보가 없는 key인 경우 text 타입이 반환된다.', () => {
const key = 'key';
vi.spyOn(JSON, 'parse').mockImplementation(() => {
return {
types: {}
}
})
const result = getPropertyType(key);
expect(result).toBe('text');
vi.clearAllMocks()
});
});
describe('addMultiPropertyValue 호출 시', () => {
let index;
let properties;
let type;
let expectedTag;
const nodes = [];
let spy;
beforeEach(() => {
spy = vi.spyOn(nodes, 'push');
})
afterEach(() => {
vi.clearAllMocks();
})
it('index+1이 properties 길이와 같으면 while문 본문이 호출되지 않는다.', () => {
properties = ['key: value']
index = properties.length-1;
type = 'text';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledTimes(2)
});
it('index+1이 properties 길이보다 크면 while문 본문이 호출되지 않는다.', () => {
properties = ['key: value']
index = 3;
type = 'text';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledTimes(2)
});
it('parseKeyAndValue의 반환 결과 key 값이 존재하면 이후의 로직이 실행되지 않는다.', () => {
properties = ['key: value', 'key2: value']
index = 0;
type = 'text';
const returnIndex = addMultiPropertyValue(nodes, type, properties, index);
expect(returnIndex).toBe(index);
});
it('key 값이 존재하지 않고 multitext 타입이면 타입에 맞는 태그가 추가된다.', () => {
const value = 'value';
expectedTag = `<div class="multi-select-pill"><div class="multi-select-pill-content">${value}</div></div>`;
properties = ['parent: parent', ' - ' + value]
index = 0;
type = 'multitext';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
import {
addMultiPropertyValue,
addPropertyValue,
getPropertyType,
parseKeyAndValue
} from "./propertyRemarkUtils.js";
import {describe, vi} from "vitest";
import fs from "fs";
function wrapTagByObject(tag) {
return {
type: 'html',
value: tag
}
}
describe('parseKeyAndValue 호출 시', () => {
let property;
it(`프로퍼티가 "-" 문자로 시작하지 않으면 ":" 문자를 기준으로 키와 값으로 분리된다.`, () => {
const keyInput = 'key'
const valueInput = 'value';
property = `${keyInput}: ${valueInput}`;
const {key, value} = parseKeyAndValue(property);
expect(key).toBe(keyInput)
expect(value).toBe(valueInput)
});
it('프로퍼티가 "-" 문자로 시작하면, 값만 반환된다.', () => {
const valueInput = 'value';
property = ` - ${valueInput}`;
const {key, value} = parseKeyAndValue(property);
expect(key).toBe('')
expect(value).toBe(valueInput)
});
});
describe('addPropertyValue 호출 시', () => {
let value = 'default value';
let expectedTag;
const nodes = [];
let spy;
beforeAll(() => {
spy = vi.spyOn(nodes, 'push');
})
afterAll(() => {
vi.clearAllMocks();
})
it('number 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input metadata-input-number" tabindex="-1" type="number" placeholder="Empty" value="${value}">`
addPropertyValue(nodes, value, 'number');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('datetime 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input metadata-input-text mod-datetime" tabindex="-1" type="datetime-local" placeholder="Empty" value="${value}">`
addPropertyValue(nodes, value, 'datetime');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('date 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input metadata-input-text mod-date is-empty" tabindex="-1" type="date" placeholder="Empty" value="${value}">`
addPropertyValue(nodes, value, 'date');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('checkbox 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<input class="metadata-input-checkbox" tabindex="-1" type="checkbox" data-indeterminate="false" ${value ? 'checked' : ''} onclick="return false;">`
addPropertyValue(nodes, value, 'checkbox');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('text 타입인 경우 알맞은 태그가 추가된다.', () => {
expectedTag = `<div class="metadata-input-longtext mod-truncate" tabindex="-1" placeholder="Empty">${value}</div>`
addPropertyValue(nodes, value, 'text');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('타입 정보가 올바르지 않으면 text 타입의 태그가 추가된다.', () => {
expectedTag = `<div class="metadata-input-longtext mod-truncate" tabindex="-1" placeholder="Empty">${value}</div>`
addPropertyValue(nodes, value, 'unknown');
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
});
describe('getPropertyType 호출 시', () => {
it('types.json 파일이 없어도 tags 키는 tags 타입이 반환된다.', () => {
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return false;
})
const result = getPropertyType('tags');
expect(result).toBe('tags');
vi.clearAllMocks();
});
it('types.json 파일이 존재하지 않는 경우 text 타입이 반환된다.', () => {
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return false;
})
const result = getPropertyType('aa');
expect(result).toBe('text');
vi.clearAllMocks();
});
it('맵에 정보가 있는 key인 경우 기록된 타입이 반환된다.', () => {
const key = 'key';
const type = 'date';
vi.spyOn(JSON, 'parse').mockImplementation(() => {
return {
types: {
[key]: `${type}`
}
}
})
const result = getPropertyType(key);
expect(result).toBe(type);
vi.clearAllMocks()
});
it('맵에 정보가 없는 key인 경우 text 타입이 반환된다.', () => {
const key = 'key';
vi.spyOn(JSON, 'parse').mockImplementation(() => {
return {
types: {}
}
})
const result = getPropertyType(key);
expect(result).toBe('text');
vi.clearAllMocks()
});
});
describe('addMultiPropertyValue 호출 시', () => {
let index;
let properties;
let type;
let expectedTag;
const nodes = [];
let spy;
beforeEach(() => {
spy = vi.spyOn(nodes, 'push');
})
afterEach(() => {
vi.clearAllMocks();
})
it('index+1이 properties 길이와 같으면 while문 본문이 호출되지 않는다.', () => {
properties = ['key: value']
index = properties.length-1;
type = 'text';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledTimes(2)
});
it('index+1이 properties 길이보다 크면 while문 본문이 호출되지 않는다.', () => {
properties = ['key: value']
index = 3;
type = 'text';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledTimes(2)
});
it('parseKeyAndValue의 반환 결과 key 값이 존재하면 이후의 로직이 실행되지 않는다.', () => {
properties = ['key: value', 'key2: value']
index = 0;
type = 'text';
const returnIndex = addMultiPropertyValue(nodes, type, properties, index);
expect(returnIndex).toBe(index);
});
it('key 값이 존재하지 않고 multitext 타입이면 타입에 맞는 태그가 추가된다.', () => {
const value = 'value';
expectedTag = `<div class="multi-select-pill"><div class="multi-select-pill-content">${value}</div></div>`;
properties = ['parent: parent', ' - ' + value]
index = 0;
type = 'multitext';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('key 값이 존재하지 않고 tags 타입이면 타입에 맞는 태그가 추가된다.', () => {
const value = 'value';
expectedTag = `<div class="multi-select-pill" tabindex="0"><div class="multi-select-pill-content"><span>${value}</span></div></div>`;
expectedTag = `<div class="multi-select-pill metadata-property-tag" tabindex="0" data-tag-value="${value}"><div class="multi-select-pill-content"><span>${value}</span></div></div>`;
properties = ['parent: parent', ' - ' + value]
index = 0;
type = 'tags';
addMultiPropertyValue(nodes, type, properties, index);
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('key 값이 없는 프로퍼티가 여러개면 모두 추가된다.', () => {
const values = ['value1', 'value2', 'value3']
properties = ['parent: parent', ` - ${values[0]}`, ` - ${values[1]}`, `- ${values[2]}`]
const expectedTags = [];
for (const value of values) {
expectedTags.push(`<div class="multi-select-pill"><div class="multi-select-pill-content">${value}</div></div>`)
}
index = 0;
type = 'multitext';
addMultiPropertyValue(nodes, type, properties, index);
for (const tag of expectedTags) {
expect(spy).toHaveBeenCalledWith(wrapTagByObject(tag));
}
});
it('마지막으로 추가한 자식 인덱스를 반환한다.', () => {
const values = ['value1', 'value2']
properties = ['parent: parent', ` - ${values[0]}`, ` - ${values[1]}`, `key: value3`]
index = 0;
type = 'multitext';
const resultIndex = addMultiPropertyValue(nodes, type, properties, index);
expect(resultIndex).toBe(index + values.length);
});
});
expect(spy).toHaveBeenCalledWith(wrapTagByObject(expectedTag))
});
it('key 값이 없는 프로퍼티가 여러개면 모두 추가된다.', () => {
const values = ['value1', 'value2', 'value3']
properties = ['parent: parent', ` - ${values[0]}`, ` - ${values[1]}`, `- ${values[2]}`]
const expectedTags = [];
for (const value of values) {
expectedTags.push(`<div class="multi-select-pill"><div class="multi-select-pill-content">${value}</div></div>`)
}
index = 0;
type = 'multitext';
addMultiPropertyValue(nodes, type, properties, index);
for (const tag of expectedTags) {
expect(spy).toHaveBeenCalledWith(wrapTagByObject(tag));
}
});
it('마지막으로 추가한 자식 인덱스를 반환한다.', () => {
const values = ['value1', 'value2']
properties = ['parent: parent', ` - ${values[0]}`, ` - ${values[1]}`, `key: value3`]
index = 0;
type = 'multitext';
const resultIndex = addMultiPropertyValue(nodes, type, properties, index);
expect(resultIndex).toBe(index + values.length);
});
});

View file

@ -3,6 +3,8 @@ import {getIndexFilePath, getMarkdownFileSet, getMarkdownSearchMap} from "./file
import TreeItem from "../components/TreeItem.jsx";
export const MIN_SEARCH_KEYWORD_LENGTH = 2;
const TAG_QUERY_PATTERN = /^tag:(#?\S+)$/i;
const calculateCounts = (node) => {
let total = 0;
@ -41,12 +43,167 @@ const buildTree = (paths) => {
return tree;
};
const normalizeTagValue = (tagValue = '') => {
return tagValue
.normalize('NFC')
.trim()
.replace(/^#/, '')
.replace(/^['"]|['"]$/g, '')
.toLowerCase();
}
const parseSearchQuery = (keyword = '') => {
const normalizedQuery = keyword.normalize('NFC').trim();
if (!normalizedQuery) {
return {
keyword: '',
tags: [],
};
}
const keywordTokens = [];
const tagSet = new Set();
const tokens = normalizedQuery.split(/\s+/);
for (const token of tokens) {
const matchedTag = token.match(TAG_QUERY_PATTERN);
if (matchedTag) {
const normalizedTag = normalizeTagValue(matchedTag[1]);
if (normalizedTag) {
tagSet.add(normalizedTag);
}
continue;
}
keywordTokens.push(token);
}
return {
keyword: keywordTokens.join(' ').trim(),
tags: [...tagSet],
};
}
const addInlineTags = (rawTagValue, tagSet) => {
if (!rawTagValue) {
return;
}
const normalizedRawTagValue = rawTagValue.trim();
if (!normalizedRawTagValue) {
return;
}
const values = normalizedRawTagValue
.replace(/^\[/, '')
.replace(/\]$/, '')
.split(',');
values.forEach((tag) => {
const normalizedTag = normalizeTagValue(tag);
if (normalizedTag) {
tagSet.add(normalizedTag);
}
});
}
const addListTags = (rawTagValue, tagSet) => {
if (!rawTagValue) {
return;
}
const listTagPattern = /-\s*(\S+)/g;
let match = listTagPattern.exec(rawTagValue);
while (match) {
const normalizedTag = normalizeTagValue(match[1]);
if (normalizedTag) {
tagSet.add(normalizedTag);
}
match = listTagPattern.exec(rawTagValue);
}
}
const extractFrontmatterContent = (markdown = '') => {
const normalizedMarkdown = markdown.normalize('NFC').trim();
const multilineFrontmatter = normalizedMarkdown.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
if (multilineFrontmatter) {
return multilineFrontmatter[1];
}
const collapsedFrontmatter = normalizedMarkdown.match(/^---\s*([\s\S]*?)\s*---/);
if (collapsedFrontmatter) {
return collapsedFrontmatter[1];
}
return '';
}
const extractTagsFromCollapsedFrontmatter = (frontmatterContent, tagSet) => {
const inlineMatch = frontmatterContent.match(/(?:^|\s)tags?\s*:\s*\[([^\]]*)\]/i);
if (inlineMatch) {
addInlineTags(inlineMatch[1], tagSet);
return;
}
const listMatch = frontmatterContent.match(/(?:^|\s)tags?\s*:\s*((?:-\s*\S+\s*)+)/i);
if (listMatch) {
addListTags(listMatch[1], tagSet);
return;
}
const scalarMatch = frontmatterContent.match(/(?:^|\s)tags?\s*:\s*(\S+)/i);
if (scalarMatch) {
const normalizedTag = normalizeTagValue(scalarMatch[1]);
if (normalizedTag) {
tagSet.add(normalizedTag);
}
}
}
const extractFrontmatterTags = (markdown = '') => {
const frontmatterContent = extractFrontmatterContent(markdown);
if (!frontmatterContent) {
return new Set();
}
const tagSet = new Set();
if (!frontmatterContent.includes('\n')) {
extractTagsFromCollapsedFrontmatter(frontmatterContent, tagSet);
return tagSet;
}
const lines = frontmatterContent.split('\n');
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const match = line.match(/^\s*tags?\s*:\s*(.*)$/i);
if (!match) {
continue;
}
addInlineTags(match[1], tagSet);
while (index + 1 < lines.length) {
const childTagMatch = lines[index + 1].match(/^\s*-\s*(\S+)\s*$/);
if (!childTagMatch) {
break;
}
const normalizedTag = normalizeTagValue(childTagMatch[1]);
if (normalizedTag) {
tagSet.add(normalizedTag);
}
index++;
}
}
return tagSet;
}
export const filterPathsByKeyword = (paths, keyword = '') => {
const normalizedKeyword = keyword.normalize('NFC').trim().toLowerCase();
if (!normalizedKeyword) {
const {keyword: parsedKeyword, tags: requiredTags} = parseSearchQuery(keyword);
const normalizedKeyword = parsedKeyword.normalize('NFC').trim().toLowerCase();
const hasTagQuery = requiredTags.length > 0;
if (!normalizedKeyword && !hasTagQuery) {
return new Set(paths);
}
if (normalizedKeyword.length < MIN_SEARCH_KEYWORD_LENGTH) {
if (!hasTagQuery && normalizedKeyword.length < MIN_SEARCH_KEYWORD_LENGTH) {
return new Set();
}
@ -54,13 +211,25 @@ export const filterPathsByKeyword = (paths, keyword = '') => {
const result = new Set();
for (const path of paths) {
const normalizedPath = path.normalize('NFC').toLowerCase();
const markdownContent = (markdownSearchMap[path] || '').normalize('NFC').toLowerCase();
if (normalizedPath.includes(normalizedKeyword) || markdownContent.includes(normalizedKeyword)) {
result.add(path);
const markdownSource = markdownSearchMap[path] || '';
const markdownContent = markdownSource.normalize('NFC').toLowerCase();
const isKeywordMatched = !normalizedKeyword || normalizedPath.includes(normalizedKeyword) || markdownContent.includes(normalizedKeyword);
if (!isKeywordMatched) {
continue;
}
if (hasTagQuery) {
const markdownTags = extractFrontmatterTags(markdownSource);
const isTagMatched = requiredTags.every((tag) => markdownTags.has(tag));
if (!isTagMatched) {
continue;
}
}
result.add(path);
}
return result;
}
return result;
}
export const initTree = (indexMarkdownPath = getIndexFilePath(), searchKeyword = '') => {
const fileSet = new Set(getMarkdownFileSet());

View file

@ -176,6 +176,84 @@ describe('initTree 호출 시', () => {
expect(tree.children.posts.children['doc-2.md']).toBeUndefined();
});
it('tag:태그명 형식으로 입력하면 해당 태그를 가진 문서만 검색된다.', () => {
fileSet = new Set(['posts/react.md', 'posts/java.md']);
searchContentMap = {
'posts/react.md': '---\ntags:\n - react\n---\nReact content',
'posts/java.md': '---\ntags:\n - java\n---\nJava content',
};
const tree = initTree('', 'tag:react');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['react.md']).toBeDefined();
expect(tree.children.posts.children['java.md']).toBeUndefined();
});
it('tag:#태그명 형식도 동일하게 동작한다.', () => {
fileSet = new Set(['posts/react.md', 'posts/java.md']);
searchContentMap = {
'posts/react.md': '---\ntags: [react]\n---\nReact content',
'posts/java.md': '---\ntags: [java]\n---\nJava content',
};
const tree = initTree('', 'tag:#react');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['react.md']).toBeDefined();
expect(tree.children.posts.children['java.md']).toBeUndefined();
});
it('영문 태그는 대소문자를 구분하지 않고 검색된다.', () => {
fileSet = new Set(['posts/react.md', 'posts/java.md']);
searchContentMap = {
'posts/react.md': '---\ntags:\n - React\n---\nReact content',
'posts/java.md': '---\ntags:\n - Java\n---\nJava content',
};
const tree = initTree('', 'tag:react');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['react.md']).toBeDefined();
expect(tree.children.posts.children['java.md']).toBeUndefined();
});
it('공백 정규화로 줄바꿈이 사라진 검색 인덱스에서도 태그 검색이 동작한다.', () => {
fileSet = new Set(['posts/react.md', 'posts/java.md']);
searchContentMap = {
'posts/react.md': '--- created: 2024-01-01 tags: - React - JS --- React content',
'posts/java.md': '--- created: 2024-01-01 tags: - Java --- Java content',
};
const tree = initTree('', 'tag:react');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['react.md']).toBeDefined();
expect(tree.children.posts.children['java.md']).toBeUndefined();
});
it('공백 정규화된 인덱스의 tags: [a, b] 형식도 태그 검색이 동작한다.', () => {
fileSet = new Set(['posts/react.md', 'posts/java.md']);
searchContentMap = {
'posts/react.md': '--- created: 2024-01-01 tags: [React, JS] --- React content',
'posts/java.md': '--- created: 2024-01-01 tags: [Java] --- Java content',
};
const tree = initTree('', 'tag:#react');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['react.md']).toBeDefined();
expect(tree.children.posts.children['java.md']).toBeUndefined();
});
it('태그 검색과 키워드 검색을 함께 입력하면 모두 일치하는 문서만 검색된다.', () => {
fileSet = new Set(['posts/react-state.md', 'posts/react-router.md']);
searchContentMap = {
'posts/react-state.md': '---\ntags:\n - react\n---\nState management details',
'posts/react-router.md': '---\ntags:\n - react\n---\nRouting details',
};
const tree = initTree('', 'tag:react state');
expect(tree.count).toBe(1);
expect(tree.children.posts.children['react-state.md']).toBeDefined();
expect(tree.children.posts.children['react-router.md']).toBeUndefined();
});
});
describe('renderTree 호출 시', () => {