Initial import
This commit is contained in:
263
src/components/markdownPreview/MarkdownPreviewCard.tsx
Executable file
263
src/components/markdownPreview/MarkdownPreviewCard.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
219
src/components/markdownPreview/MarkdownPreviewContent.tsx
Executable file
219
src/components/markdownPreview/MarkdownPreviewContent.tsx
Executable 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>;
|
||||
}
|
||||
55
src/components/markdownPreview/MarkdownPreviewList.tsx
Executable file
55
src/components/markdownPreview/MarkdownPreviewList.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
267
src/components/markdownPreview/WorklogSourcePreview.tsx
Executable file
267
src/components/markdownPreview/WorklogSourcePreview.tsx
Executable 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
src/components/markdownPreview/index.ts
Executable file
8
src/components/markdownPreview/index.ts
Executable 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';
|
||||
8
src/components/markdownPreview/markdown-document.ts
Executable file
8
src/components/markdownPreview/markdown-document.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export type MarkdownDocument = {
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
path: string;
|
||||
content: string;
|
||||
folder: string;
|
||||
};
|
||||
63
src/components/markdownPreview/registry.ts
Executable file
63
src/components/markdownPreview/registry.ts
Executable 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),
|
||||
);
|
||||
}
|
||||
29
src/components/markdownPreview/samples/MarkdownPreviewCardBaseSample.tsx
Executable file
29
src/components/markdownPreview/samples/MarkdownPreviewCardBaseSample.tsx
Executable 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- 적치 확정`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/components/markdownPreview/samples/MarkdownPreviewContentBaseSample.tsx
Executable file
23
src/components/markdownPreview/samples/MarkdownPreviewContentBaseSample.tsx
Executable 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\`\`\``}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/components/markdownPreview/samples/MarkdownPreviewListBaseSample.tsx
Executable file
40
src/components/markdownPreview/samples/MarkdownPreviewListBaseSample.tsx
Executable 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- 패킹`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user