라이브 작성 블록 렌더링과 발행 시각 보정

This commit is contained in:
2026-07-02 16:01:45 +09:00
parent 0576069427
commit 7f20949669
15 changed files with 222 additions and 20 deletions

View File

@@ -1545,6 +1545,12 @@ watch(publishStatus, (next) => {
}
})
watch(() => form.status, (next) => {
if (next === 'published' && !toIsoDateTime(form.publishedAt)) {
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
}
})
watch(serializedPostFingerprint, () => {
if (!import.meta.client) {
return

View File

@@ -109,6 +109,7 @@ const onBodyInput = (payload) => {
<ContentMarkdownEditableInline
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="multiline"
block-markdown
arrow-exit-creates-line
preserve-empty-line-on-full-delete
:source-line="bodySourceLine"

View File

@@ -2,6 +2,7 @@
import {
getEditableCaretOffset,
markdownInlineToHtml,
markdownMultilineBlockToHtml,
markdownMultilineInlineToHtml,
readEditableTextFromElement,
setEditableCaretOffset
@@ -96,6 +97,11 @@ const props = defineProps({
type: Boolean,
default: false
},
/** true면 멀티라인 본문 안의 목록 같은 블록 문법도 라이브 표시한다. */
blockMarkdown: {
type: Boolean,
default: false
},
/** 전체 선택 삭제 시 비어 있는 원본 줄을 보존할지 여부 */
preserveEmptyLineOnFullDelete: {
type: Boolean,
@@ -167,6 +173,12 @@ const toEditorHtml = () => markdownInlineToHtml(props.modelValue.replace(/\n/g,
*/
const toMultilineEditorHtml = () => markdownMultilineInlineToHtml(props.modelValue)
/**
* modelValue를 블록 마크다운 포함 멀티라인 편집용 HTML로 변환한다.
* @returns {string} HTML
*/
const toMultilineBlockEditorHtml = () => markdownMultilineBlockToHtml(props.modelValue)
/**
* plainText 모드용 편집 텍스트를 만든다.
* @param {string} value - 본문
@@ -197,7 +209,7 @@ const syncEditorHtml = (options = {}) => {
}
rootRef.value.innerHTML = resolvedEnterMode.value === 'multiline'
? toMultilineEditorHtml()
? (props.blockMarkdown ? toMultilineBlockEditorHtml() : toMultilineEditorHtml())
: toEditorHtml()
}
@@ -1551,6 +1563,22 @@ defineExpose({ focusEditor, readEditorValue })
color: #fff;
}
.content-markdown-editable-inline :deep(ul),
.content-markdown-editable-inline :deep(ol) {
margin: 0.35rem 0;
padding-left: 1.25rem;
}
.content-markdown-editable-inline :deep(li) {
margin: 0.15rem 0;
padding-left: 0.15rem;
}
.content-markdown-editable-inline :deep(li::marker) {
color: #2eb6ea;
font-weight: 700;
}
.content-markdown-editable-inline :deep([data-inline-color='gray']) {
color: var(--site-muted);
}

View File

@@ -3193,6 +3193,7 @@ onBeforeUnmount(() => {
block-class="content-markdown-renderer__quote-line"
:model-value="block.text"
enter-mode="multiline"
block-markdown
arrow-exit-creates-line
preserve-empty-line-on-full-delete
empty-markdown-line="> "
@@ -3343,7 +3344,7 @@ onBeforeUnmount(() => {
:title="block.title"
:background="block.calloutBackground"
>
<ContentMarkdownInlineSegments :text="block.text" multiline />
<ContentMarkdownRenderer :content="block.text" />
</ProseCallout>
<ContentMarkdownToggleEditor
v-else-if="block.type === 'toggle' && interactive"
@@ -3358,7 +3359,7 @@ onBeforeUnmount(() => {
@delete-line="onDeleteLine"
/>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'" :default-open="block.defaultOpen" animated>
<ContentMarkdownInlineSegments :text="block.text" />
<ContentMarkdownRenderer :content="block.text" />
</ProseToggle>
<ProseBookmark
v-else-if="block.type === 'bookmark' && block.meta.url"

View File

@@ -136,7 +136,7 @@ const onExitBelow = (payload) => {
block-class="content-markdown-toggle-editor__body min-h-[3rem] outline-none"
enter-mode="multiline"
navigation-scope="parent"
plain-text
block-markdown
arrow-exit-creates-line
:source-line="bodySourceLine"
:source-line-count="String(modelValue ?? '').split('\n').length"

View File

@@ -62,7 +62,10 @@ const toggleOpen = () => {
</svg>
</button>
<div class="prose-toggle__title min-w-0 flex-1 text-[15px] font-semibold leading-7 text-[var(--site-text)]">
<div
class="prose-toggle__title min-w-0 flex-1 cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]"
@click="toggleOpen"
>
<slot name="title">
{{ title || '더 보기' }}
</slot>

View File

@@ -1,5 +1,12 @@
# 업데이트 요약
## v1.10.19
- 라이브 글쓰기의 인용·콜아웃·토글 안에서 굵게와 기본 목록 표시가 실제 화면에 가깝게 보이도록 보정했다.
- 콜아웃·토글 공개 화면에서도 내부 목록 문법이 적용되도록 바꿨다.
- 토글 블록은 제목을 눌러도 펼치거나 접을 수 있게 했다.
- 비공개/초안 글을 공개로 직접 바꿀 때 발행 시각이 비지 않도록 현재 시각을 자동 입력한다.
## v1.10.18
- 관리자 미디어 관리 화면이 필요한 페이지의 미디어만 받아오도록 바꿔, 파일이 많아질 때 목록 로딩 부담을 줄였다.

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-07-02 v1.10.19 — 구조 블록 안의 기본 마크다운도 작성 화면에서 보여 준다
인용·콜아웃·토글은 문서 안의 보조 구조지만, 내부에 선택지 목록이나 강조 링크가 들어가는 경우가 많다. 공개 화면과 라이브 작성 화면의 차이가 커지면 작성자는 저장 후 결과를 다시 확인해야 하므로, 기본 굵게·링크·색상뿐 아니라 불릿/번호 목록까지 라이브 표시와 저장 왕복을 맞춘다. 다만 복잡한 중첩 블록 편집기는 별도 구조가 필요하므로 이번 범위는 기본 목록과 인라인 문법에 한정한다.
기본 목록을 라이브 편집용 HTML로 바꾸면 브라우저 DOM의 커서 위치와 실제 저장되는 `- 항목` 원문 줄의 길이가 달라진다. 방향키 이동과 줄 삭제는 원문 줄 기준으로 작동해야 하므로 contenteditable offset 계산도 목록 마커와 항목 사이 줄바꿈을 포함하도록 맞춘다. 공개 토글은 닫힌 상태에서 chevron만 눌러야 하는 구조가 명확하지 않으므로 제목 클릭도 펼침·접힘 트리거로 포함한다.
게시물을 비공개나 초안에서 공개로 직접 바꿀 때 발행 시각이 비면 공개 정렬과 예약 판별이 불안정해진다. 발행 모달 경로뿐 아니라 상태 셀렉트와 API 직접 요청에서도 같은 결과가 나오도록 클라이언트와 서버 양쪽에서 빈 발행 시각을 현재 시각으로 보정한다.
## 2026-07-02 v1.10.18 — 미디어 관리는 API 응답부터 나눠 받는다
v1.10.16에서 관리자 미디어 화면 렌더링은 50·100·200개 단위로 나눴지만, API는 여전히 전체 미디어 배열을 내려줄 수 있었다. 미디어가 수백 개 이상으로 늘면 화면 렌더링을 나눠도 네트워크 payload와 클라이언트 메모리 부담이 남는다. 따라서 미디어 관리 화면은 `paged=1` 응답을 사용해 탭·폴더·종류·미사용·검색 조건이 적용된 현재 페이지 항목과 전체 개수 메타만 받는다. 글쓰기 이미지 선택과 대표 이미지 선택은 원본 전체 선택 흐름이 아직 필요하므로 기존 배열 응답을 유지해 호환성을 우선했다.

View File

@@ -40,7 +40,7 @@
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
| lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `<br>` 줄바꿈 처리 |
| lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `<br>` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 |
| lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 |
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
@@ -131,7 +131,7 @@
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
@@ -154,8 +154,8 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 인라인 색상·굵게·링크 렌더링, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 멀티라인 인라인 마크다운 렌더링, plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 인라인 색상·굵게·링크 렌더링, 라이브 인용·콜아웃·토글 기본 목록 렌더링과 멀티라인 편집 줄 범위 포커스, 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 공개 콜아웃·토글 내부 본문 블록 렌더링, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 멀티라인 인라인 마크다운 렌더링, `blockMarkdown` 옵션 기반 기본 불릿/번호 목록 표시와 공개 목록 마커 색상 적용, plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·굵게·기울임·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 |
| components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 |
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
@@ -166,10 +166,10 @@
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 선택 줄번호 gutter, 공개 복사 버튼) |
| components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) |
| components/content/ProseButton.vue | 버튼 |
| components/content/ContentMarkdownCalloutEditor.vue | 라이브 모드 콜아웃 멀티라인 본문 편집, 본문 인라인 굵게·링크·색상 렌더링, 제목·아이콘 표시 여부·배경 반영, 내부 여러 줄 범위 선택 |
| components/content/ContentMarkdownCalloutEditor.vue | 라이브 모드 콜아웃 멀티라인 본문 편집, 본문 인라인 굵게·링크·색상·기본 목록 렌더링, 제목·아이콘 표시 여부·배경 반영, 내부 여러 줄 범위 선택 |
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 선택 제목, 인용과 같은 배경 프리셋, 헤더 아래 본문 줄바꿈) |
| components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron 트리거) |
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집, 저장된 기본 펼침·닫힘 상태 초기 반영 |
| components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron·제목 클릭 트리거) |
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집, 본문 인라인·기본 목록 렌더링, 저장된 기본 펼침·닫힘 상태 초기 반영 |
| components/content/ProseVideo.vue | `:::video` 공개 비디오 카드 |
| components/content/ProseAudio.vue | `:::audio` 공개 오디오 플레이어 카드 |
| components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드, 제목·파일명이 같으면 용량 중심 보조 정보 표시 |
@@ -324,7 +324,7 @@
| server/utils/request-ip.js | 프록시 헤더 포함 요청 IP 조회 유틸리티 |
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·Unicode 원본명 기반 파일명·WebP 변환·중앙 1:1 크롭·저장 유틸 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 Zod 검증 스키마, 자동 생성 이미지 URL의 대표 이미지 지정 차단 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 Zod 검증 스키마, 자동 생성 이미지 URL의 대표 이미지 지정 차단, 공개 상태의 빈 발행 시각 현재 시각 보정 |
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |

View File

@@ -93,7 +93,7 @@
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 공개 회원 로그인 세션의 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다. 별도 관리자 세션만 있거나 비로그인 상태에서는 표시하지 않는다.
@@ -102,6 +102,7 @@
- 공개 페이지 `<title>`은 개별 페이지 제목이 있으면 제목만 사용하고, 제목이 없는 기본 화면에서만 사이트 이름을 사용한다. 게시물 상세 제목 뒤에 사이트 이름을 자동으로 붙이지 않는다.
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 이때 코드·HTML/iframe 블록, HTML 태그, 인라인 색상 토큰 같은 표시용 문법은 목록 설명에 노출하지 않는다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다. 목록 카드의 제목·썸네일·오른쪽 아래 화살표는 게시물 상세로 이동하고, 태그 배지는 태그 고유 색상 배경으로 표시하며 `/tag/:slug`로 이동한다.
- 게시물 상세 제목 아래 대표 이미지는 관리자 글쓰기 오른쪽 사이드의 `대표 이미지 표시` 옵션이 켜진 게시물에서만 표시한다. 해당 토글은 대표 이미지 유무와 상관없이 항상 활성 상태이며, 대표 이미지를 나중에 추가할 경우에도 미리 저장한 표시 설정을 유지한다. 대표 이미지는 목록·공유용으로는 계속 사용할 수 있으며, 본문 상단 표시 기본값은 꺼짐이다.
- 관리자 글쓰기에서 비공개·초안 상태 글을 `published`로 직접 변경했는데 발행 시각이 비어 있으면 현재 시각을 기본값으로 채운다. 서버 저장 API도 공개 상태 입력의 `publishedAt`이 비어 있으면 현재 시각으로 보정한다.
### 게시물 업로드 이미지 썸네일
@@ -792,9 +793,9 @@ components/content/
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`, `title`을 저장할 수 있다. 예: `:::callout emoji=none bg=blue title="주의사항"`
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
- 콜아웃 본문은 일반 문단과 같은 인라인 마크다운 규칙을 사용하며, 공개 화면과 라이브 편집 화면 모두 여러 줄·굵게·링크·색상 조합을 렌더링한다.
- 콜아웃 본문은 일반 문단과 같은 인라인 마크다운 규칙을 사용하며, 공개 화면과 라이브 편집 화면 모두 여러 줄·굵게·링크·색상·기본 불릿/번호 목록 조합을 렌더링한다. 라이브 편집 목록은 공개 목록과 같은 마커 색상을 사용하고, 위/아래 방향키는 목록 항목 줄을 원문 줄 기준으로 이동한다.
- 콜아웃 배경 프리셋은 인용 블록과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`를 지원한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 저장된 기본 펼침·닫힘 상태로 처음 표시한 뒤 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 저장된 기본 펼침·닫힘 상태로 처음 표시한 뒤 제목·본문을 인라인 편집하며, 공개 화면에서는 chevron 또는 제목 클릭으로 펼침·접힘 시 본문이 애니메이션된다.
- 링크 블록은 `:::link` fenced block 안에 URL을 저장하고 `ProseEmbed`로 렌더링한다. 이미지 파일 URL은 링크 블록이 아니라 이미지 블록으로 저장한다.
- iframe 블록은 `:::iframe` fenced block 안에 iframe HTML을 저장한다. 렌더링 시 `src`, `title`, `allow`, `width`, `height`, `allowfullscreen` 등 제한된 속성만 추출해 sandbox iframe으로 표시한다.
- HTML 블록은 `:::html` fenced block 안에 사용자 HTML을 저장하고 `ProseHtml`로 렌더링한다. 본문 HTML에서는 script, iframe, object, form 계열 태그와 이벤트 속성, `javascript:` URL을 제거한다. iframe은 `/iframe` 블록을 사용한다.

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v1.10.19
- 라이브 작성 인용·콜아웃·토글 본문에서 멀티라인 블록 마크다운 표시를 보강.
- 콜아웃·토글 공개 렌더링에서 내부 본문을 블록 렌더러로 처리해 목록 문법이 적용되도록 수정.
- 공개 토글 블록에서 화살표뿐 아니라 제목을 눌러도 펼침·접힘이 전환되도록 수정.
- 라이브 작성 콜아웃·토글 내부 목록의 마커 색상과 위/아래 방향키 줄 이동 계산을 공개 렌더링 기준에 맞게 보강.
- 게시물 상태를 공개로 직접 변경할 때 발행 시각이 비어 있으면 현재 시각을 기본값으로 채우도록 수정.
- 관리자 게시물 저장 API에서도 공개 상태의 빈 발행 시각을 현재 시각으로 보정하도록 보강.
## v1.10.18
- 관리자 미디어 관리 화면이 `/admin/api/media?paged=1` 페이지 응답을 사용하도록 수정.

View File

@@ -373,6 +373,64 @@ export const markdownMultilineInlineToHtml = (value) => String(value || '')
.map((line) => markdownInlineToHtml(line))
.join('<br>')
/**
* 여러 줄 마크다운을 라이브 편집용 블록 HTML로 변환한다.
* @param {string} value - 줄바꿈 포함 마크다운
* @returns {string} HTML
*/
export const markdownMultilineBlockToHtml = (value) => {
const lines = String(value || '').replace(/<br\s*\/?>/gi, '\n').split('\n')
const htmlParts = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const bulletMatch = line.match(/^\s*-\s+(.*)$/)
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/)
if (bulletMatch) {
const items = []
while (index < lines.length) {
const itemMatch = lines[index].match(/^\s*-\s+(.*)$/)
if (!itemMatch) {
break
}
items.push(`<li>${markdownInlineToHtml(itemMatch[1])}</li>`)
index += 1
}
htmlParts.push(`<ul>${items.join('')}</ul>`)
continue
}
if (orderedMatch) {
const items = []
while (index < lines.length) {
const itemMatch = lines[index].match(/^\s*\d+\.\s+(.*)$/)
if (!itemMatch) {
break
}
items.push(`<li>${markdownInlineToHtml(itemMatch[1])}</li>`)
index += 1
}
htmlParts.push(`<ol>${items.join('')}</ol>`)
continue
}
htmlParts.push(line ? markdownInlineToHtml(line) : '<br>')
index += 1
}
return htmlParts.join('<br>')
}
/**
* 인라인 HTML 노드를 마크다운 문자열로 변환한다.
* @param {Node} node - HTML 노드
@@ -421,6 +479,24 @@ export const convertHtmlInlineNodeToMarkdown = (node) => {
return isInlineColorName(inlineColor) ? formatInlineColorMarkdown(inlineColor, childText) : childText
}
if (tagName === 'ul') {
return Array.from(element.children)
.filter((child) => child.tagName.toLowerCase() === 'li')
.map((child) => `- ${Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('')}`)
.join('\n')
}
if (tagName === 'ol') {
return Array.from(element.children)
.filter((child) => child.tagName.toLowerCase() === 'li')
.map((child, index) => `${index + 1}. ${Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('')}`)
.join('\n')
}
if (tagName === 'li') {
return childText
}
if (tagName === 'img') {
const src = element.getAttribute('src')
const alt = element.getAttribute('alt') || ''
@@ -503,6 +579,33 @@ function* iterateEditableTextUnits(root) {
return
}
if (tag === 'ul' || tag === 'ol') {
if (parentIsRoot && indexInParent > 0) {
yield { kind: 'block-break', node: null, length: 1 }
}
const items = [...element.children].filter((child) => child.tagName.toLowerCase() === 'li')
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
if (itemIndex > 0) {
yield { kind: 'block-break', node: null, length: 1 }
}
const marker = tag === 'ol' ? `${itemIndex + 1}. ` : '- '
const item = items[itemIndex]
yield { kind: 'marker', node: item, length: marker.length }
const children = [...item.childNodes]
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
yield* visit(children[childIndex], false, childIndex)
}
}
return
}
if (isEditableBlockBreak(element, root)) {
if (parentIsRoot && indexInParent > 0) {
yield { kind: 'block-break', node: null, length: 1 }
@@ -671,6 +774,27 @@ export const setEditableCaretOffset = (root, targetOffset) => {
continue
}
if (unit.kind === 'marker' && unit.node?.nodeType === Node.ELEMENT_NODE) {
if (walked + unit.length >= safeOffset) {
const textWalker = document.createTreeWalker(unit.node, NodeFilter.SHOW_TEXT)
const firstText = textWalker.nextNode()
if (firstText) {
const range = document.createRange()
range.setStart(firstText, 0)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
placed = true
break
}
}
walked += unit.length
continue
}
if (walked + unit.length >= safeOffset) {
const range = document.createRange()
range.selectNodeContents(root)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.18",
"version": "1.10.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.18",
"version": "1.10.19",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.18",
"version": "1.10.19",
"private": true,
"type": "module",
"imports": {

View File

@@ -48,4 +48,18 @@ export const adminPostInputSchema = z.object({
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminPostInput = (body) => adminPostInputSchema.safeParse(body)
export const parseAdminPostInput = (body) => {
const parsed = adminPostInputSchema.safeParse(body)
if (!parsed.success || parsed.data.status !== 'published' || parsed.data.publishedAt) {
return parsed
}
return {
...parsed,
data: {
...parsed.data,
publishedAt: new Date().toISOString()
}
}
}