게시물 Import 호환성 보강 v1.5.28

This commit is contained in:
2026-06-02 10:30:11 +09:00
parent ef1a9d9032
commit 9d91355c81
11 changed files with 117 additions and 19 deletions

View File

@@ -146,6 +146,55 @@ const parseYamlValue = (value) => {
return unquoteYamlString(trimmed)
}
/**
* Markdown frontmatter 줄을 키와 값으로 나눈다.
* @param {string} line - frontmatter 한 줄
* @returns {{ key: string, value: string } | null} 키와 값
*/
const parseFrontmatterLine = (line) => {
const match = String(line || '').match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (!match) {
return null
}
return {
key: match[1].trim(),
value: match[2]
}
}
/**
* YAML 블록 배열 값을 수집한다.
* @param {Array<string>} lines - frontmatter 줄 목록
* @param {number} startIndex - 시작 인덱스
* @returns {{ values: Array<string>, nextIndex: number }} 수집 결과
*/
const collectYamlBlockArray = (lines, startIndex) => {
const values = []
let index = startIndex
while (index < lines.length) {
const line = lines[index]
const itemMatch = String(line || '').match(/^\s*-\s*(.*)$/)
if (!itemMatch) {
break
}
const value = unquoteYamlString(itemMatch[1])
if (value) {
values.push(value)
}
index += 1
}
return {
values,
nextIndex: index
}
}
/**
* Markdown frontmatter와 본문을 분리한다.
* @param {string} markdown - Markdown 문서
@@ -173,19 +222,26 @@ const parseMarkdownDocument = (markdown) => {
const frontmatterText = normalized.slice(4, endIndex)
const content = normalized.slice(endIndex + 4).replace(/^\n/, '')
const frontmatter = {}
const lines = frontmatterText.split('\n')
for (const line of frontmatterText.split('\n')) {
const separatorIndex = line.indexOf(':')
if (separatorIndex < 0) {
for (let index = 0; index < lines.length; index += 1) {
const parsedLine = parseFrontmatterLine(lines[index])
if (!parsedLine) {
continue
}
const key = line.slice(0, separatorIndex).trim()
const value = line.slice(separatorIndex + 1)
const { key, value } = parsedLine
const trimmedValue = String(value || '').trim()
if (key) {
frontmatter[key] = parseYamlValue(value)
if (!trimmedValue && lines[index + 1]?.match(/^\s*-\s*/)) {
const blockArray = collectYamlBlockArray(lines, index + 1)
frontmatter[key] = blockArray.values
index = blockArray.nextIndex - 1
continue
}
frontmatter[key] = parseYamlValue(value)
}
return {
@@ -271,7 +327,7 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
* @param {Map<string, Buffer>} input.entryMap - ZIP 엔트리 맵
* @param {string} input.postFolder - 게시물 폴더
* @param {Set<string>} input.assetPaths - 자산 경로 목록
* @returns {Promise<Map<string, string>>} 원본 경로별 새 URL
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
*/
const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
const now = new Date()
@@ -279,6 +335,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
const month = String(now.getMonth() + 1).padStart(2, '0')
const directoryPath = join(process.cwd(), 'public', 'uploads', 'posts', year, month)
const replacements = new Map()
const missingAssets = []
await mkdir(directoryPath, { recursive: true })
@@ -287,6 +344,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
const data = entryMap.get(entryPath)
if (!data) {
missingAssets.push(assetPath)
continue
}
@@ -301,7 +359,10 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
}
return replacements
return {
replacements,
missingAssets
}
}
/**
@@ -464,7 +525,7 @@ const collectMarkdownPosts = (entries) => entries
/**
* Export ZIP을 게시물로 가져온다.
* @param {{ zipBuffer: Buffer, authorId: string }} input - Import 입력
* @returns {Promise<{ importedCount: number, assetCount: number, posts: Array<Object> }>} Import 결과
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
*/
export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
const entries = readZipBufferEntries(zipBuffer)
@@ -481,17 +542,21 @@ export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
const reservedSlugs = new Set()
const importedPosts = []
const warnings = []
let importedAssetCount = 0
for (const markdownPost of markdownPosts) {
const { frontmatter, content, postFolder } = markdownPost
const assetPaths = collectImportAssetPaths({ frontmatter, content })
const replacements = await saveImportAssets({
const { replacements, missingAssets } = await saveImportAssets({
entryMap,
postFolder,
assetPaths
})
importedAssetCount += replacements.size ? new Set(replacements.values()).size : 0
missingAssets.forEach((assetPath) => {
warnings.push(`${markdownPost.path}: ZIP 내부 자산을 찾지 못했습니다. (${assetPath})`)
})
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
const slug = await createUniquePostSlug(frontmatter.slug || title, reservedSlugs)
@@ -525,6 +590,8 @@ export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
return {
importedCount: importedPosts.length,
assetCount: importedAssetCount,
warningCount: warnings.length,
warnings: warnings.slice(0, 20),
posts: importedPosts.map((post) => ({
id: post.id,
title: post.title,

View File

@@ -7,7 +7,7 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
/**
* 관리자 게시물 Import API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ importedCount: number, assetCount: number, posts: Array<Object> }>} Import 결과
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
*/
export default defineEventHandler(async (event) => {
const adminSession = requireAdminSession(event)