레거시 본문 저장 형식 정규화
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
type: [String, Array, Object],
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
@@ -30,7 +32,7 @@ const imageWidthOptions = [
|
||||
]
|
||||
|
||||
const markdownValue = computed({
|
||||
get: () => props.modelValue || '',
|
||||
get: () => normalizeMarkdownContent(props.modelValue),
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
|
||||
const props = defineProps({
|
||||
initialPost: {
|
||||
type: Object,
|
||||
@@ -101,7 +103,7 @@ const form = reactive({
|
||||
title: props.initialPost.title || '',
|
||||
slug: props.initialPost.slug || '',
|
||||
excerpt: props.initialPost.excerpt || '',
|
||||
content: props.initialPost.content || '',
|
||||
content: normalizeMarkdownContent(props.initialPost.content),
|
||||
featuredImage: props.initialPost.featuredImage || '',
|
||||
noindex: Boolean(props.initialPost.noindex),
|
||||
status: props.initialPost.status || 'draft',
|
||||
@@ -297,7 +299,7 @@ const createPostPayload = () => {
|
||||
title: form.title.trim(),
|
||||
slug: toSlug(form.slug || form.title),
|
||||
excerpt: form.excerpt.trim(),
|
||||
content: form.content,
|
||||
content: normalizeMarkdownContent(form.content),
|
||||
featuredImage: form.featuredImage.trim() || null,
|
||||
seoTitle: form.title.trim(),
|
||||
seoDescription: form.excerpt.trim(),
|
||||
@@ -326,7 +328,7 @@ const createAutosavePayload = () => ({
|
||||
title: form.title,
|
||||
slug: form.slug,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
content: normalizeMarkdownContent(form.content),
|
||||
featuredImage: form.featuredImage,
|
||||
noindex: form.noindex,
|
||||
status: form.status,
|
||||
@@ -407,7 +409,10 @@ const restoreAutosave = () => {
|
||||
}
|
||||
|
||||
isRestoringAutosave.value = true
|
||||
Object.assign(form, autosaveNotice.value.payload)
|
||||
Object.assign(form, {
|
||||
...autosaveNotice.value.payload,
|
||||
content: normalizeMarkdownContent(autosaveNotice.value.payload.content)
|
||||
})
|
||||
slugTouched.value = Boolean(form.slug)
|
||||
autosaveStatus.value = `${formatAutosaveTime(autosaveNotice.value.savedAt)} 자동 저장본 복원됨`
|
||||
autosaveNotice.value = null
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.0.14
|
||||
|
||||
- Markdown-first 전환 후 레거시 블록 본문이나 기존 자동 저장본 때문에 게시물 발행이 막히는 문제를 보강.
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-14 v1.0.14
|
||||
|
||||
### Markdown-first 전환 후 레거시 본문 정규화
|
||||
|
||||
Markdown-first 에디터로 전환한 뒤에도 브라우저 자동 저장본이나 이전 블록 에디터 상태가 배열·객체 형태로 남아 있으면, 저장 API의 `content: string` 검증에서 “게시물 입력 형식” 오류가 날 수 있다. 데이터베이스 저장 형식은 마크다운 문자열로 유지하되, 클라이언트 복원 단계와 서버 입력 검증 단계에 공통 정규화 유틸을 두어 레거시 블록 값을 마크다운으로 변환한다. 이렇게 하면 사용자가 기존 자동 저장본을 복원해도 발행 흐름이 끊기지 않고, 이후 페이지 편집 쪽도 같은 기준을 공유한다.
|
||||
|
||||
## 2026-05-14 v1.0.13
|
||||
|
||||
### Markdown 에디터 붙여넣기와 미디어 편집 보강
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
|
||||
## Nuxt 모듈
|
||||
|
||||
|
||||
@@ -443,6 +443,7 @@ components/content/
|
||||
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
|
||||
- 작성 모드 textarea 왼쪽에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 두고, 캐럿이 있는 줄 번호 행에 배경색으로 **활성 표시**를 한다. textarea와 거터의 세로 스크롤은 동기화한다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
|
||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.0.14
|
||||
|
||||
- 관리자 게시물/페이지 입력 스키마에서 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 정규화하도록 보강.
|
||||
- `AdminMarkdownEditor`와 `AdminPostForm`에서 기존 자동 저장본 또는 레거시 블록 본문을 복원할 때 마크다운 문자열로 변환하도록 수정.
|
||||
- 공통 `normalizeMarkdownContent` 유틸 추가.
|
||||
- 패키지 버전 `1.0.14`로 갱신.
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 `AdminMarkdownEditor`에 HTML 클립보드 붙여넣기 기본 변환을 추가해 외부 블로그/웹 문서를 붙여넣을 때 제목·문단·목록·링크·굵게·기울임·이미지를 마크다운 조각으로 정리.
|
||||
|
||||
172
lib/markdown-content-normalizer.js
Normal file
172
lib/markdown-content-normalizer.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
|
||||
const blockSpacingTypes = new Set(['list'])
|
||||
|
||||
/**
|
||||
* 이미지 블록을 마크다운 문자열로 변환한다.
|
||||
* @param {Object} image - 이미지 데이터
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const serializeImageBlock = (image = {}) => {
|
||||
const url = String(image.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 블록 하나를 마크다운 조각으로 변환한다.
|
||||
* @param {Object} block - 레거시 에디터 블록
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @param {number} total - 전체 블록 수
|
||||
* @returns {{ type: string, value: string }|null} 마크다운 조각
|
||||
*/
|
||||
const serializeLegacyBlock = (block = {}, index = 0, total = 1) => {
|
||||
if (typeof block.value === 'string') {
|
||||
return block.value.trim()
|
||||
? { type: block.type || 'paragraph', value: block.value }
|
||||
: null
|
||||
}
|
||||
|
||||
const type = block.type || 'paragraph'
|
||||
const rawText = String(block.text || '')
|
||||
const text = rawText.trim()
|
||||
|
||||
if (type === 'divider') {
|
||||
return { type, value: '---' }
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
const image = serializeImageBlock(block)
|
||||
return image ? { type, value: image } : null
|
||||
}
|
||||
|
||||
if (type === 'gallery') {
|
||||
const images = Array.isArray(block.images)
|
||||
? block.images.map(serializeImageBlock).filter(Boolean)
|
||||
: []
|
||||
|
||||
return images.length
|
||||
? { type, value: [':::gallery', ...images, ':::'].join('\n') }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'callout') {
|
||||
const emoji = block.calloutEmojiEnabled === false
|
||||
? 'none'
|
||||
: (block.calloutEmoji || '💡')
|
||||
const background = block.calloutBackground || 'blue'
|
||||
|
||||
return text
|
||||
? { type, value: `:::callout emoji=${emoji} bg=${background}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'toggle') {
|
||||
const title = String(block.title || '').trim()
|
||||
|
||||
return title || text
|
||||
? { type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'embed') {
|
||||
const url = String(block.url || '').trim()
|
||||
|
||||
return url
|
||||
? { type, value: `:::embed\n${url}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'paragraph' && !text) {
|
||||
return index === total - 1
|
||||
? null
|
||||
: { type, value: BLANK_PARAGRAPH_MARKER }
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (type === 'heading') {
|
||||
return { type, value: `${'#'.repeat(block.level || 2)} ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'quote') {
|
||||
return { type, value: `> ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'list') {
|
||||
return { type, value: `- ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'code') {
|
||||
return { type, value: `\`\`\`\n${rawText}\n\`\`\`` }
|
||||
}
|
||||
|
||||
return { type, value: text }
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 블록 배열을 저장용 마크다운 문자열로 변환한다.
|
||||
* @param {Array<Object>} blocks - 레거시 블록 목록
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const serializeLegacyBlocks = (blocks) => blocks
|
||||
.map((block, index) => serializeLegacyBlock(block, index, blocks.length))
|
||||
.filter(Boolean)
|
||||
.reduce((markdown, block, index, blocksList) => {
|
||||
if (index === 0) {
|
||||
return block.value
|
||||
}
|
||||
|
||||
const previousBlock = blocksList[index - 1]
|
||||
const joiner = blockSpacingTypes.has(previousBlock.type) && blockSpacingTypes.has(block.type)
|
||||
? '\n'
|
||||
: '\n\n'
|
||||
|
||||
return `${markdown}${joiner}${block.value}`
|
||||
}, '')
|
||||
|
||||
/**
|
||||
* 게시물/페이지 본문 값을 저장 가능한 마크다운 문자열로 정규화한다.
|
||||
* @param {unknown} value - 본문 값
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
export const normalizeMarkdownContent = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return serializeLegacyBlocks(value)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
if (typeof value.content === 'string') {
|
||||
return value.content
|
||||
}
|
||||
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return serializeLegacyBlocks(value.blocks)
|
||||
}
|
||||
|
||||
if (typeof value.markdown === 'string') {
|
||||
return value.markdown
|
||||
}
|
||||
|
||||
if (typeof value.type === 'string') {
|
||||
const block = serializeLegacyBlock(value)
|
||||
return block?.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
|
||||
export const adminPageInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
content: z.string().default(''),
|
||||
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
|
||||
featuredImage: z.string().trim().nullable().default(null)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { postStatusSchema } from './content-schema'
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
import { postStatusSchema } from './content-schema.js'
|
||||
|
||||
export const adminPostInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
content: z.string().default(''),
|
||||
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
|
||||
excerpt: z.string().default(''),
|
||||
featuredImage: z.string().trim().nullable().default(null),
|
||||
seoTitle: z.string().trim().default(''),
|
||||
|
||||
Reference in New Issue
Block a user