Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
import { Card, Tabs, Typography } from 'antd';
import type { MarkdownDocument } from './markdown-document';
import { MarkdownPreviewContent } from './MarkdownPreviewContent';
import { WorklogSourcePreview } from './WorklogSourcePreview';
const { Paragraph, Text, Title } = Typography;
const ARTIFACT_SECTION_TITLE_PATTERN =
/^(##|###)\s+(스크린샷|화면 캡처|산출물|확인 경로|참고 링크|preview|source|소스|실행 커맨드|변경 파일|신규 파일|변경\/신규 파일)/i;
const INLINE_ARTIFACT_PATTERN = /!\[[^\]]*\]\(([^)]+)\)|\[[^\]]+\]\(([^)]+)\)|`([^`]+\.(?:png|jpg|jpeg|gif|webp|svg|md|ts|tsx|js|jsx|css|json|mjs))`|(https?:\/\/\S+)/gi;
export type MarkdownPreviewCardProps = {
document: MarkdownDocument;
};
type WorklogSections = {
mainContent: string;
artifactSections: ArtifactSection[];
};
type ArtifactSection = {
key: string;
label: string;
content: string;
count: number;
};
type WorklogTabItem = {
key: string;
label: string;
count?: number;
content: string;
};
function finalizeArtifactSection(section: ArtifactSection) {
return {
...section,
content: section.content.trim(),
};
}
function mergeArtifactSections(sections: ArtifactSection[]) {
const mergedSections: ArtifactSection[] = [];
for (const section of sections) {
const normalizedSection = finalizeArtifactSection(section);
const existingSection = mergedSections.find((item) => item.label === normalizedSection.label);
if (!existingSection) {
mergedSections.push({
...normalizedSection,
key: `${mergedSections.length}-${normalizedSection.label.toLowerCase()}`,
});
continue;
}
const existingBody = existingSection.content.replace(/^#{2,3}\s+.+\n?/u, '').trim();
const nextBody = normalizedSection.content.replace(/^#{2,3}\s+.+\n?/u, '').trim();
const mergedBody = [existingBody, nextBody].filter(Boolean).join('\n\n').trim();
existingSection.content = `## ${existingSection.label}${mergedBody ? `\n\n${mergedBody}` : ''}`;
}
mergedSections.forEach((section) => {
section.count = section.content.match(INLINE_ARTIFACT_PATTERN)?.length ?? 0;
});
return mergedSections.filter((section) => section.content);
}
function normalizeArtifactSectionLabel(rawTitle: string) {
const normalizedTitle = rawTitle.trim().toLowerCase();
if (normalizedTitle === '화면 캡처' || normalizedTitle === '스크린샷') {
return '스크린샷';
}
if (normalizedTitle === 'source' || normalizedTitle === '소스') {
return '소스';
}
if (normalizedTitle === 'preview') {
return 'Preview';
}
if (normalizedTitle === '실행 커맨드') {
return '커맨드';
}
if (
normalizedTitle === '변경 파일' ||
normalizedTitle === '신규 파일' ||
normalizedTitle === '변경/신규 파일'
) {
return '파일';
}
return rawTitle.trim();
}
function splitWorklogContent(content: string): WorklogSections {
const lines = content.split('\n');
const mainLines: string[] = [];
const artifactSections: ArtifactSection[] = [];
let currentSection: ArtifactSection | null = null;
let inCodeFence = false;
for (const line of lines) {
const trimmedLine = line.trim();
const isFenceLine = /^```/.test(trimmedLine);
if (isFenceLine) {
inCodeFence = !inCodeFence;
if (currentSection) {
currentSection.content += `${line}\n`;
} else {
mainLines.push(line);
}
continue;
}
const match = inCodeFence ? null : trimmedLine.match(ARTIFACT_SECTION_TITLE_PATTERN);
const isHeading = !inCodeFence && /^#{2,3}\s+/.test(trimmedLine);
if (match) {
if (currentSection) {
artifactSections.push(finalizeArtifactSection(currentSection));
}
const rawTitle = match[2] ?? '자료';
currentSection = {
key: `${artifactSections.length}-${rawTitle.toLowerCase()}`,
label: normalizeArtifactSectionLabel(rawTitle),
content: `${line}\n`,
count: 0,
};
continue;
}
if (isHeading) {
if (currentSection) {
artifactSections.push(finalizeArtifactSection(currentSection));
}
currentSection = null;
mainLines.push(line);
continue;
}
if (currentSection) {
currentSection.content += `${line}\n`;
} else {
mainLines.push(line);
}
}
if (currentSection) {
artifactSections.push(finalizeArtifactSection(currentSection));
}
return {
mainContent: mainLines.join('\n').trim(),
artifactSections: mergeArtifactSections(artifactSections),
};
}
function isRepoPathBullet(line: string) {
return /^-\s+.*`(?:src|docs|scripts|etc|public|\.github)\/[^`]+`/.test(line);
}
function sanitizeDetailedWorklogSection(content: string) {
const lines = content.split('\n');
const sanitizedLines: string[] = [];
let inDetailedSection = false;
let inCodeFence = false;
for (const line of lines) {
const trimmedLine = line.trim();
if (!inCodeFence && /^##\s+상세 작업 내역\b/.test(trimmedLine)) {
inDetailedSection = true;
sanitizedLines.push(line);
continue;
}
if (!inCodeFence && inDetailedSection && /^##\s+/.test(trimmedLine)) {
inDetailedSection = false;
}
if (/^```/.test(trimmedLine)) {
if (inDetailedSection) {
inCodeFence = !inCodeFence;
continue;
}
sanitizedLines.push(line);
continue;
}
if (inDetailedSection) {
if (isRepoPathBullet(trimmedLine)) {
continue;
}
if (/^(diff --git|@@\s|--- a\/|\+\+\+ b\/)/.test(trimmedLine)) {
continue;
}
}
sanitizedLines.push(line);
}
return sanitizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
export function MarkdownPreviewCard({ document }: MarkdownPreviewCardProps) {
const isWorklog = document.folder === 'worklogs';
const { mainContent, artifactSections } = splitWorklogContent(document.content);
const filesSection = artifactSections.find((section) => section.label === '파일');
const visibleArtifactSections = isWorklog
? artifactSections.filter((section) => section.label !== '파일')
: artifactSections;
const worklogTabs: WorklogTabItem[] = isWorklog
? [
{
key: 'worklog-main',
label: '작업일지',
content: sanitizeDetailedWorklogSection(mainContent || document.content),
},
...visibleArtifactSections,
]
: [];
return (
<Card className="feature-markdown-card">
<Title level={4}>{document.title}</Title>
{isWorklog ? (
<Tabs
className="feature-markdown-card__tabs"
items={worklogTabs.map((section) => ({
key: section.key,
label: `${section.label}${section.count && section.count > 0 ? ` (${section.count})` : ''}`,
children:
section.label === '소스' ? (
<WorklogSourcePreview
sourceSectionContent={section.content}
filesSectionContent={filesSection?.content}
/>
) : section.content ? (
<MarkdownPreviewContent content={section.content} documentPath={document.path} />
) : (
<Paragraph type="secondary" className="feature-markdown-card__artifacts-copy">
.
</Paragraph>
),
}))}
/>
) : (
<MarkdownPreviewContent content={document.content} documentPath={document.path} />
)}
<Text type="secondary" code>
{document.path}
</Text>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
import { Typography } from 'antd';
import { InlineImage } from '../common/InlineImage';
import { CodexDiffBlock } from '../previewer';
import { inferCodeLanguage, renderEditorBlock } from '../previewer/renderers';
const { Paragraph, Text, Title } = Typography;
const docsAssetUrls = import.meta.glob('/docs/assets/**/*.{png,jpg,jpeg,gif,webp,svg}', {
eager: true,
import: 'default',
}) as Record<string, string>;
type MarkdownPreviewContentProps = {
content: string;
documentPath?: string;
maxBlocks?: number;
};
function resolveRelativePath(basePath: string, targetPath: string) {
const baseSegments = basePath.split('/').filter(Boolean);
baseSegments.pop();
for (const segment of targetPath.split('/').filter(Boolean)) {
if (segment === '.') {
continue;
}
if (segment === '..') {
baseSegments.pop();
continue;
}
baseSegments.push(segment);
}
return `/${baseSegments.join('/')}`;
}
function resolveImageSource(documentPath: string | undefined, imagePath: string) {
if (/^(https?:)?\/\//.test(imagePath) || imagePath.startsWith('data:')) {
return imagePath;
}
const normalizedPath = imagePath.startsWith('/')
? imagePath
: resolveRelativePath(documentPath ?? '/', imagePath);
return docsAssetUrls[normalizedPath] ?? normalizedPath;
}
function resolveLinkHref(documentPath: string | undefined, href: string) {
if (/^(https?:)?\/\//.test(href) || href.startsWith('mailto:') || href.startsWith('#')) {
return href;
}
const normalizedPath = href.startsWith('/')
? href
: resolveRelativePath(documentPath ?? '/', href);
return docsAssetUrls[normalizedPath] ?? normalizedPath;
}
function renderInline(text: string, documentPath?: string) {
const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^)]+\))/g);
return parts.map((part, index) => {
if (part.startsWith('`') && part.endsWith('`')) {
return (
<Text code key={`${part}-${index}`}>
{part.slice(1, -1)}
</Text>
);
}
const markdownLinkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (markdownLinkMatch) {
const [, label, href] = markdownLinkMatch;
return (
<a
key={`${part}-${index}`}
href={resolveLinkHref(documentPath, href.trim())}
target="_blank"
rel="noreferrer"
>
{label.trim()}
</a>
);
}
return part;
});
}
export function MarkdownPreviewContent({
content,
documentPath,
maxBlocks,
}: MarkdownPreviewContentProps) {
const lines = content.split('\n');
const blocks: React.ReactNode[] = [];
let index = 0;
const hasBlockLimit = Number.isFinite(maxBlocks);
while (index < lines.length && (!hasBlockLimit || blocks.length < (maxBlocks ?? 0))) {
const line = lines[index].trimEnd();
if (!line.trim()) {
index += 1;
continue;
}
if (line.startsWith('### ')) {
blocks.push(
<Title level={5} key={`h3-${index}`}>
{line.slice(4)}
</Title>,
);
index += 1;
continue;
}
if (line.startsWith('## ')) {
blocks.push(
<Title level={4} key={`h2-${index}`}>
{line.slice(3)}
</Title>,
);
index += 1;
continue;
}
if (line.startsWith('# ')) {
blocks.push(
<Title level={3} key={`h1-${index}`}>
{line.slice(2)}
</Title>,
);
index += 1;
continue;
}
if (line.startsWith('- ')) {
const items: string[] = [];
while (index < lines.length && lines[index].trim().startsWith('- ')) {
items.push(lines[index].trim().slice(2));
index += 1;
}
blocks.push(
<ul className="markdown-preview__list" key={`ul-${index}`}>
{items.map((item, itemIndex) => (
<li key={`${item}-${itemIndex}`}>{renderInline(item, documentPath)}</li>
))}
</ul>,
);
continue;
}
if (line.startsWith('```')) {
const codeLanguage = inferCodeLanguage(line.slice(3).trim() || 'text');
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index]);
index += 1;
}
index += 1;
blocks.push(
codeLanguage === 'diff' ? (
<div className="markdown-preview__code" key={`code-${index}`}>
<CodexDiffBlock diffText={codeLines.join('\n')} />
</div>
) : (
<div className="markdown-preview__code" key={`code-${index}`}>
{renderEditorBlock(codeLines.join('\n'), codeLanguage, 'code')}
</div>
),
);
continue;
}
const imageMatch = line.match(/^!\[(.*)\]\((.+)\)$/);
if (imageMatch) {
const [, alt, src] = imageMatch;
blocks.push(
<InlineImage
key={`img-${index}`}
className="markdown-preview__image"
src={resolveImageSource(documentPath, src.trim())}
alt={alt.trim() || 'markdown image'}
fallbackText="문서 이미지를 불러오지 못했습니다."
/>,
);
index += 1;
continue;
}
const paragraphLines: string[] = [line];
index += 1;
while (index < lines.length && lines[index].trim() && !/^#{1,3}\s/.test(lines[index].trim())) {
if (lines[index].trim().startsWith('- ') || lines[index].trim().startsWith('```')) {
break;
}
paragraphLines.push(lines[index].trim());
index += 1;
}
blocks.push(
<Paragraph key={`p-${index}`}>{renderInline(paragraphLines.join(' '), documentPath)}</Paragraph>,
);
}
return <div className="markdown-preview">{blocks}</div>;
}

View File

@@ -0,0 +1,55 @@
import { Empty, Flex } from 'antd';
import { useEffect, useState } from 'react';
import { MarkdownPreviewCard } from './MarkdownPreviewCard';
import type { MarkdownDocument } from './markdown-document';
import type { MarkdownDocumentEntry } from './registry';
import { resolveMarkdownDocuments } from './registry';
export type MarkdownPreviewListProps = {
entries: MarkdownDocumentEntry[];
basePath?: string;
documents?: MarkdownDocument[];
emptyDescription?: string;
};
export function MarkdownPreviewList({
entries,
basePath,
documents,
emptyDescription = '표시할 markdown 문서가 없습니다.',
}: MarkdownPreviewListProps) {
const [resolvedDocuments, setResolvedDocuments] = useState<MarkdownDocument[]>(documents ?? []);
useEffect(() => {
if (documents) {
setResolvedDocuments(documents);
return;
}
let mounted = true;
void resolveMarkdownDocuments(entries, basePath).then((loadedDocuments) => {
if (mounted) {
setResolvedDocuments(loadedDocuments);
}
});
return () => {
mounted = false;
};
}, [basePath, documents, entries]);
if (resolvedDocuments.length === 0) {
return <Empty description={emptyDescription} />;
}
return (
<Flex vertical gap={16} className="feature-markdown-list">
{resolvedDocuments.map((document) => (
<div key={document.id} className="feature-markdown-list__item">
<MarkdownPreviewCard document={document} />
</div>
))}
</Flex>
);
}

View File

@@ -0,0 +1,267 @@
import { Empty } from 'antd';
import { CodexDiffPreviewer } from '../previewer';
import type { CodexDiffPreviewerFile, CodexDiffPreviewerFileStatus } from '../previewer';
const repoTextModules = {
...import.meta.glob('/src/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/docs/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/scripts/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/etc/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/.github/**/*.{json,md,mjs,yml,yaml}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/{README.md,package.json,package-lock.json,docker-compose.yml,.gitignore}', {
eager: true,
query: '?raw',
import: 'default',
}),
} as Record<string, string>;
const repoImageModules = {
...import.meta.glob('/src/**/*.{png,jpg,jpeg,gif,webp,svg,avif}', {
eager: true,
import: 'default',
}),
...import.meta.glob('/docs/**/*.{png,jpg,jpeg,gif,webp,svg,avif}', {
eager: true,
import: 'default',
}),
} as Record<string, string>;
type WorklogSourcePreviewProps = {
sourceSectionContent: string;
filesSectionContent?: string;
};
type ParsedChangedFile = {
path: string;
previousPath: string | null;
status: CodexDiffPreviewerFileStatus;
};
const BINARY_FILE_PATTERN =
/\.(?:png|jpe?g|gif|webp|svg|ico|pdf|zip|gz|tar|woff2?|ttf|eot|mp3|mp4|mov|avi|webm)$/i;
const IMAGE_FILE_PATTERN = /\.(?:png|jpe?g|gif|webp|svg|avif)$/i;
function stripSectionHeading(content: string) {
return content.replace(/^#{2,3}\s+.+\n?/, '').trim();
}
function normalizeRepoPath(path: string) {
return path.replace(/^`|`$/g, '').replace(/^\/+/, '').trim();
}
function inferLanguageFromPath(path: string) {
const extension = path.split('.').pop()?.toLowerCase();
switch (extension) {
case 'ts':
return 'ts';
case 'tsx':
return 'tsx';
case 'js':
return 'js';
case 'jsx':
return 'jsx';
case 'css':
return 'css';
case 'scss':
return 'scss';
case 'html':
return 'html';
case 'json':
return 'json';
case 'md':
return 'md';
case 'mjs':
return 'js';
case 'yml':
case 'yaml':
return 'yaml';
case 'sh':
return 'bash';
default:
return 'text';
}
}
function parseChangedFiles(filesSectionContent?: string) {
if (!filesSectionContent?.trim()) {
return [];
}
return stripSectionHeading(filesSectionContent)
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('- '))
.map((line) => line.slice(2).trim())
.map<ParsedChangedFile | null>((line) => {
const renamedMatch = line.match(/^R\s+(.+?)\s+->\s+(.+)$/);
if (renamedMatch) {
return {
path: normalizeRepoPath(renamedMatch[2]),
previousPath: normalizeRepoPath(renamedMatch[1]),
status: 'renamed',
};
}
const statusMatch = line.match(/^([A-Z])\s+(.+)$/);
if (statusMatch) {
const [, rawStatus, rawPath] = statusMatch;
const normalizedPath = normalizeRepoPath(rawPath);
return {
path: normalizedPath,
previousPath: null,
status:
rawStatus === 'A'
? 'added'
: rawStatus === 'M'
? 'modified'
: rawStatus === 'D'
? 'deleted'
: rawStatus === 'R'
? 'renamed'
: BINARY_FILE_PATTERN.test(normalizedPath)
? 'binary'
: 'unknown',
};
}
if (!line || /^저장소 기준/.test(line)) {
return null;
}
const normalizedPath = normalizeRepoPath(line);
return normalizedPath
? {
path: normalizedPath,
previousPath: null,
status: BINARY_FILE_PATTERN.test(normalizedPath) ? 'binary' : 'unknown',
}
: null;
})
.filter((file): file is ParsedChangedFile => Boolean(file));
}
function parseSourcePaths(sourceSectionContent: string) {
const body = stripSectionHeading(sourceSectionContent);
const headingPaths = body
.split('\n')
.map((line) => line.trim())
.map((line) => line.match(/^###\s+파일\s+\d+:\s+`([^`]+)`$/)?.[1] ?? null)
.filter((path): path is string => Boolean(path));
const inlinePaths = Array.from(
body.matchAll(/`((?:src|docs|scripts|etc|public|\.github)\/[^`\n]+?\.[a-z0-9]+)`/gi),
).map((match) => match[1]);
const diffPaths = Array.from(body.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)).flatMap((match) => [
match[1],
match[2],
]);
return Array.from(new Set([...headingPaths, ...inlinePaths, ...diffPaths])).map((path) => ({
path: normalizeRepoPath(path),
previousPath: null,
status: 'unknown' as const,
}));
}
function parseRawDiffText(sourceSectionContent: string) {
const matches = Array.from(sourceSectionContent.matchAll(/```diff\s*\n([\s\S]*?)\n```/g));
return matches.map((match) => match[1].trimEnd()).filter(Boolean).join('\n\n');
}
function buildPreviewFiles(entries: ParsedChangedFile[]): CodexDiffPreviewerFile[] {
return entries.map((entry) => {
const normalizedPath = `/${entry.path}`;
const isPublicFile = entry.path.startsWith('public/');
const publicAssetUrl = isPublicFile ? `/${entry.path.slice('public/'.length)}` : null;
const rawContent = repoTextModules[normalizedPath];
const imageUrl = repoImageModules[normalizedPath] ?? (IMAGE_FILE_PATTERN.test(entry.path) ? publicAssetUrl : null);
const isBinary = entry.status === 'binary' || BINARY_FILE_PATTERN.test(entry.path);
const isImage = IMAGE_FILE_PATTERN.test(entry.path);
return {
path: entry.path,
previousPath: entry.previousPath,
status: isBinary ? 'binary' : entry.status,
previewType:
isImage && imageUrl
? 'image'
: entry.status === 'deleted'
? 'empty'
: 'code',
language: inferLanguageFromPath(entry.path),
content: isImage && imageUrl
? imageUrl
: isBinary
? '바이너리 파일은 전체 소스를 표시하지 않습니다.'
: isPublicFile
? 'public 디렉터리 파일은 번들 import 없이 정적 URL로만 제공되어 전체 소스 미리보기를 표시하지 않습니다.'
: rawContent ??
(entry.status === 'deleted'
? '삭제된 파일이라 현재 저장소에서 전체 소스를 불러올 수 없습니다.'
: isImage
? '이미지 파일 URL을 현재 저장소에서 찾지 못했습니다.'
: '현재 저장소에서 파일 내용을 불러올 수 없습니다.'),
};
});
}
function mergeSourceEntries(changedFiles: ParsedChangedFile[], sourcePaths: ParsedChangedFile[]) {
const merged = new Map<string, ParsedChangedFile>();
for (const entry of sourcePaths) {
merged.set(entry.path, entry);
}
for (const entry of changedFiles) {
merged.set(entry.path, entry);
}
return Array.from(merged.values());
}
export function WorklogSourcePreview({
sourceSectionContent,
filesSectionContent,
}: WorklogSourcePreviewProps) {
const diffText = parseRawDiffText(sourceSectionContent);
const changedFiles = parseChangedFiles(filesSectionContent);
const sourcePaths = mergeSourceEntries(changedFiles, parseSourcePaths(sourceSectionContent));
const files = buildPreviewFiles(sourcePaths);
const description = diffText
? '변경 파일 기준 전체 소스와 raw diff를 Codex preview 스타일로 전환해 표시합니다.'
: '변경 파일 기준 전체 소스를 Codex preview 스타일로 표시합니다. raw diff는 작업일지 `## 소스` 섹션의 diff 코드블록을 그대로 사용합니다.';
if (!files.length && !diffText) {
return <Empty description="기록된 소스 미리보기가 없습니다." />;
}
return (
<CodexDiffPreviewer
title="작업 소스"
description={description}
files={files}
diffText={diffText || null}
height="auto"
/>
);
}

View File

@@ -0,0 +1,8 @@
export { MarkdownPreviewCard } from './MarkdownPreviewCard';
export type { MarkdownPreviewCardProps } from './MarkdownPreviewCard';
export { MarkdownPreviewList } from './MarkdownPreviewList';
export type { MarkdownPreviewListProps } from './MarkdownPreviewList';
export { MarkdownPreviewContent } from './MarkdownPreviewContent';
export { resolveMarkdownDocuments } from './registry';
export type { MarkdownDocument } from './markdown-document';
export type { MarkdownDocumentEntry } from './registry';

View File

@@ -0,0 +1,8 @@
export type MarkdownDocument = {
id: string;
title: string;
preview: string;
path: string;
content: string;
folder: string;
};

View File

@@ -0,0 +1,63 @@
import type { MarkdownDocument } from './markdown-document';
export type MarkdownDocumentEntry = {
id?: string;
title?: string;
folder?: string;
path: string;
order?: number;
load: () => Promise<string>;
};
function extractTitle(markdown: string, fallback: string) {
const heading = markdown.match(/^#\s+(.+)$/m);
return heading?.[1]?.trim() || fallback;
}
function extractPreview(markdown: string) {
const normalized = markdown
.replace(/^#.+$/gm, '')
.replace(/^##.+$/gm, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`/g, '')
.replace(/\n+/g, ' ')
.trim();
return normalized.slice(0, 180);
}
export async function resolveMarkdownDocuments(
entries: MarkdownDocumentEntry[],
basePath?: string,
) {
const filteredEntries = basePath
? entries.filter((entry) => entry.path.includes(basePath))
: entries;
const loadedDocuments = await Promise.all(
filteredEntries.map(async (entry) => {
const content = await entry.load();
const fallback = entry.path.split('/').at(-1)?.replace(/\.md$/, '') || 'document';
const relativePath = basePath ? entry.path.split(basePath)[1] || '' : entry.path;
const folder = entry.folder ?? (relativePath.split('/').filter(Boolean)[0] || 'root');
return {
id: entry.id ?? entry.path.replace(/[^\w-]+/g, '-'),
title: entry.title ?? extractTitle(content, fallback),
preview: extractPreview(content),
path: entry.path,
content,
folder,
} satisfies MarkdownDocument;
}),
);
return loadedDocuments.sort(
(left, right) =>
((filteredEntries.find((entry) => entry.path === left.path)?.order ??
Number.MAX_SAFE_INTEGER) -
(filteredEntries.find((entry) => entry.path === right.path)?.order ??
Number.MAX_SAFE_INTEGER)) ||
left.title.localeCompare(right.title),
);
}

View File

@@ -0,0 +1,29 @@
import type { SampleMeta } from '../../../widgets/core';
import { MarkdownPreviewCard } from '../MarkdownPreviewCard';
export const sampleMeta: SampleMeta = {
id: 'markdown-preview-card-base',
componentId: 'markdown-preview-card',
title: 'Markdown Preview Card',
description: 'markdown 문서를 카드 레이아웃으로 감싸서 렌더링하는 컴포넌트입니다.',
category: 'Docs',
kind: 'base',
variantLabel: 'Base',
order: 84,
features: ['docs'],
};
export function Sample() {
return (
<MarkdownPreviewCard
document={{
id: 'guide-1',
title: '운영 가이드',
preview: '입고와 출고 절차 요약',
path: '/docs/guides/operations.md',
folder: 'guides',
content: `# 운영 가이드\n## 입고\n- 입고 검수\n- 적치 확정`,
}}
/>
);
}

View File

@@ -0,0 +1,23 @@
import type { SampleMeta } from '../../../widgets/core';
import { MarkdownPreviewContent } from '../MarkdownPreviewContent';
export const sampleMeta: SampleMeta = {
id: 'markdown-preview-content-base',
componentId: 'markdown-preview-content',
title: 'Markdown Preview Content',
description: '단일 markdown 본문을 렌더링하는 콘텐츠 컴포넌트입니다.',
category: 'Docs',
kind: 'base',
variantLabel: 'Base',
order: 83,
features: ['docs'],
};
export function Sample() {
return (
<MarkdownPreviewContent
documentPath="/docs/guides/sample.md"
content={`# 운영 가이드\n## 오늘 처리\n- 입고 검수\n- 피킹 확인\n\n\`\`\`ts\nconst ready = true;\n\`\`\``}
/>
);
}

View File

@@ -0,0 +1,40 @@
import type { SampleMeta } from '../../../widgets/core';
import { MarkdownPreviewList } from '../MarkdownPreviewList';
export const sampleMeta: SampleMeta = {
id: 'markdown-preview-list-base',
componentId: 'markdown-preview-list',
title: 'Markdown Preview List',
description: '여러 markdown 문서를 목록 형태로 렌더링하는 컴포넌트입니다.',
category: 'Docs',
kind: 'base',
variantLabel: 'Base',
order: 85,
features: ['docs'],
};
export function Sample() {
return (
<MarkdownPreviewList
entries={[]}
documents={[
{
id: 'guide-1',
title: '입고 작업 가이드',
preview: '입고 절차',
path: '/docs/guides/inbound.md',
folder: 'guides',
content: `# 입고 작업 가이드\n- 검수\n- 적치`,
},
{
id: 'guide-2',
title: '출고 작업 가이드',
preview: '출고 절차',
path: '/docs/guides/outbound.md',
folder: 'guides',
content: `# 출고 작업 가이드\n- 피킹\n- 패킹`,
},
]}
/>
);
}