From 00cb4a293d5a18af2e0e162d56da3cdd7fefcdcf Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Jul 2026 15:24:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=9C=EC=95=84=EC=9B=83=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=A7=81=ED=81=AC=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownCalloutEditor.vue | 1 - .../content/ContentMarkdownInlineSegments.vue | 32 +++- .../content/ContentMarkdownRenderer.vue | 2 +- docs/changelog.md | 5 + docs/deploy.md | 2 +- docs/history.md | 4 + docs/map.md | 8 +- docs/spec.md | 7 +- docs/update.md | 6 + lib/markdown-inline.js | 159 ++++++++++++++---- package-lock.json | 4 +- package.json | 2 +- 12 files changed, 182 insertions(+), 50 deletions(-) diff --git a/components/content/ContentMarkdownCalloutEditor.vue b/components/content/ContentMarkdownCalloutEditor.vue index 08adec8..7d2ab40 100644 --- a/components/content/ContentMarkdownCalloutEditor.vue +++ b/components/content/ContentMarkdownCalloutEditor.vue @@ -109,7 +109,6 @@ const onBodyInput = (payload) => { `content-markdown-inline-segments__color- :key="`inline-segment-0-${segmentIndex}`" class="content-markdown-inline-segments__segment" > - {{ segment.text }} - {{ segment.text }} - {{ segment.text }} - {{ segment.text }} + + + + + + + + + + + + {{ segment.text }} @@ -72,10 +80,18 @@ const getInlineColorClass = (color) => `content-markdown-inline-segments__color- :key="`inline-segment-${offsetIndex + 1}-${segmentIndex}`" class="content-markdown-inline-segments__segment" > - {{ segment.text }} - {{ segment.text }} - {{ segment.text }} - {{ segment.text }} + + + + + + + + + + + + {{ segment.text }} diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 662b889..9b0357c 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -3343,7 +3343,7 @@ onBeforeUnmount(() => { :title="block.title" :background="block.calloutBackground" > - + 로컬 기준 v1.10.16에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.17에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 diff --git a/docs/history.md b/docs/history.md index dfcc94d..9340223 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-07-02 v1.10.17 — 콜아웃도 일반 문단과 같은 인라인 조합을 쓴다 + +콜아웃은 안내 문맥 안에서 게시물 내부 링크를 강조하는 경우가 많다. `**[링크](url)**`처럼 굵은 링크를 쓰면 기존 인라인 렌더러가 굵게 세그먼트 내부를 문자열로만 출력해 링크가 동작하지 않았다. 따라서 굵게·기울임·색상 세그먼트도 내부 인라인 문법을 재귀 렌더링하도록 맞추고, 콜아웃 라이브 편집 본문도 인용과 같은 멀티라인 인라인 렌더러를 사용하게 했다. + ## 2026-07-02 v1.10.16 — 라이브 작성과 미디어 관리는 표시 상태를 신뢰 기준으로 맞춘다 관리자 라이브 모드는 공개 렌더링과 작성 편의 사이에 있지만, 인용문 안의 색상·굵게 토큰이 그대로 보이면 작성자는 실제 공개 화면을 예측하기 어렵다. 따라서 인용 멀티라인 편집도 일반 인라인 렌더러를 사용해 색상·굵게 같은 문법을 표시한다. 토글은 편집 편의를 위해 항상 펼치는 것보다 저장된 기본 접힘 상태를 확인할 수 있어야 하므로, 라이브 모드 진입 시 선언 줄의 기본 상태를 우선 반영한다. diff --git a/docs/map.md b/docs/map.md index 433b438..323d5b4 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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[텍스트]`) 변환, 링크·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리 | +| lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리 | | lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | | lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) | @@ -154,9 +154,9 @@ | 파일 | 화면 위치 | |------|-----------| | components/content/ContentRenderer.vue | 게시물/페이지 본문 | -| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `
` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 인라인 색상·굵게 렌더링, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 | +| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `
` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 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/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 | +| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·굵게·기울임·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 | | components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 | | lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 | | components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 | @@ -166,7 +166,7 @@ | 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 | 라이브 모드 토글 제목·본문 인라인 편집, 저장된 기본 펼침·닫힘 상태 초기 반영 | diff --git a/docs/spec.md b/docs/spec.md index 779258f..341ee81 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -88,7 +88,7 @@ - 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다. - 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성 - 공개 게시물 오른쪽 목차는 H1~H3 제목 표시 텍스트에서 링크·굵게·기울임·인라인 코드 같은 마크다운 장식만 제거하고 `~` 같은 일반 제목 문자는 보존한다. -- 공개 Markdown 인라인 렌더러는 단일 루트와 `display: contents`를 사용해 사용자 화면 DOM의 Vue fragment 주석 반복을 줄이고, 기존 인라인 흐름은 유지한다. 링크 텍스트와 색상 토큰 내부의 굵게·기울임·인라인 코드·색상·첨자 문법은 재귀적으로 렌더링한다. 여러 줄 문단은 첫 줄과 이후 줄을 분리해 첫 줄에 불필요한 `br` 조건 분기를 두지 않는다. +- 공개 Markdown 인라인 렌더러는 단일 루트와 `display: contents`를 사용해 사용자 화면 DOM의 Vue fragment 주석 반복을 줄이고, 기존 인라인 흐름은 유지한다. 링크·굵게·기울임·색상 토큰 내부의 굵게·기울임·링크·인라인 코드·색상·첨자 문법은 재귀적으로 렌더링한다. 여러 줄 문단은 첫 줄과 이후 줄을 분리해 첫 줄에 불필요한 `br` 조건 분기를 두지 않는다. - 공개 Markdown 본문 블록 렌더링은 `ContentMarkdownBlockGroup`으로 블록 단위 루트를 정리하고, 인아티클 광고는 별도 `v-if` 대신 렌더 블록 목록에 삽입한다. - Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다. - 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `
` 또는 `
`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다. @@ -723,9 +723,9 @@ components/content/ - 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다. - `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다. - 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다. -- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 ``·`` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다. +- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 ``·`` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다. - 라이브 모드 공통 편집기는 한글 IME 조합 확정 Enter를 문단·빈 줄의 분리, 제목·목록의 아래 줄 삽입, 토글 제목의 본문 이동, 인용·콜아웃·코드·토글 본문의 텍스트 줄바꿈에 동일하게 연결한다. IME가 확정 Enter의 `keydown`을 전달하지 않고 `keyup`만 남기는 경우에도 조합 종료 직후 Enter로 보고 같은 동작을 실행한다. Shift+방향키 범위 선택은 `ContentMarkdownRenderer` Selection Bridge가 인접 편집 블록까지 확장한다. 단일 줄 블록의 Shift+위/아래는 커서 위치와 상관없이 현재 열 기준으로 이전·다음 편집 블록까지 확장하고, 멀티라인 편집 영역은 첫 줄·마지막 줄 경계에서 `data-source-line`~`data-source-line-end` 줄 범위로 탐색한다. `Cmd/Ctrl+A` 1회는 현재 블록 전체, 짧은 시간 안 2회는 라이브 본문 전체를 선택한다. 교차 블록·전체 선택 상태에서 **Backspace**·**Delete**·**Cmd/Ctrl+X**는 선택 범위를 마크다운 줄로 변환해 `content-replace`로 본문을 갱신한다. 콜아웃·인용 내부 전체 선택 삭제는 블록을 제거하지 않고 빈 본문 줄을 남긴다. 블록 이동 단축키는 Shift 조합일 때 실행하지 않으며, 선택 제스처가 우선한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 값 치환 방식으로 줄바꿈을 삽입하며, contenteditable 값을 읽을 때 끝 줄바꿈을 보존해 첫 줄 빈 줄과 후행 빈 줄이 소스·라이브 모드 전환 또는 저장 중 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들고 커서를 해당 문단으로 이동하지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다. -- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰과 `::color[텍스트]` 색상 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다. 색상 토큰은 `::gray[덜 중요한 말]`, `::red[**중요한 말**]`처럼 저장하며 `gray`, `blue`, `green`, `yellow`, `red`, `purple`만 허용한다. 색상 토큰 내부의 굵게·기울임·인라인 코드·링크·첨자는 재귀적으로 렌더링한다. +- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰과 `::color[텍스트]` 색상 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다. 색상 토큰은 `::gray[덜 중요한 말]`, `::red[**중요한 말**]`처럼 저장하며 `gray`, `blue`, `green`, `yellow`, `red`, `purple`만 허용한다. 색상 토큰 내부의 굵게·기울임·인라인 코드·링크·첨자와 `**[링크](url)**` 같은 굵은 링크는 재귀적으로 렌더링한다. - 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다. - 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다. - 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 라이브러리 목록을 갱신한다. 업로드 중에는 추가 드롭·파일 선택과 모달 닫기를 막고 로딩 스피너를 표시하며, 업로드 완료 후에는 모달을 자동으로 닫거나 본문에 자동 삽입하지 않는다. 본문 삽입은 라이브러리 목록에서 파일을 직접 선택한 뒤 `삽입` 버튼을 눌렀을 때만 실행한다. 미디어 선택 목록에는 원본 파일만 표시하고, 게시물 카드·본문 1600px·본문 720px·라이트박스·Pages 커버 자동 생성 이미지는 숨긴다. @@ -791,6 +791,7 @@ 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으로 펼침·접힘 시 본문이 애니메이션된다. - 링크 블록은 `:::link` fenced block 안에 URL을 저장하고 `ProseEmbed`로 렌더링한다. 이미지 파일 URL은 링크 블록이 아니라 이미지 블록으로 저장한다. diff --git a/docs/update.md b/docs/update.md index bfbe16c..1389197 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.10.17 + +- 콜아웃 라이브 편집 본문에서도 굵게·링크 등 인라인 마크다운이 표시되도록 수정. +- 콜아웃 공개 렌더링에서 여러 줄 본문과 인라인 마크다운을 함께 처리하도록 수정. +- `**[링크](url)**`, `::red[**[링크](url)**]`처럼 굵게·색상 안에 링크가 들어간 중첩 인라인 문법을 렌더링하도록 보강. + ## v1.10.16 - 관리자 라이브 인용 편집에서 `::red[**텍스트**]` 같은 인라인 색상·굵게 문법을 렌더링하도록 수정. diff --git a/lib/markdown-inline.js b/lib/markdown-inline.js index 7ed1be8..cb687d4 100644 --- a/lib/markdown-inline.js +++ b/lib/markdown-inline.js @@ -1,8 +1,11 @@ /** @type {string[]} 인라인 색상 프리셋 */ export const INLINE_COLOR_NAMES = ['gray', 'blue', 'green', 'yellow', 'red', 'purple'] +/** @type {RegExp} 인라인 색상 시작 패턴 */ +const INLINE_COLOR_START_RE = /::(gray|blue|green|yellow|red|purple)\[/g + /** @type {RegExp} 인라인 마크다운 패턴 */ -const INLINE_MARKDOWN_RE = /(::(gray|blue|green|yellow|red|purple)\[((?:\\\]|[^\]\n])+)\]|\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g +const INLINE_MARKDOWN_RE = /(\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g /** * 지원하는 인라인 색상인지 확인한다. @@ -149,6 +152,77 @@ export const formatInlineColorMarkdown = (color, value) => { return `::${safeColor}[${text}]` } +/** + * 색상 토큰 본문을 대괄호 중첩 기준으로 읽는다. + * @param {string} source - 원본 문자열 + * @param {number} startIndex - 색상 토큰 시작 위치 + * @param {string} color - 색상 이름 + * @returns {{ color: string, text: string, nextIndex: number }|null} 색상 토큰 + */ +const readInlineColorToken = (source, startIndex, color) => { + const prefix = `::${color}[` + const contentStartIndex = startIndex + prefix.length + let depth = 1 + let escaped = false + + for (let index = contentStartIndex; index < source.length; index += 1) { + const character = source[index] + + if (escaped) { + escaped = false + continue + } + + if (character === '\\') { + escaped = true + continue + } + + if (character === '[') { + depth += 1 + continue + } + + if (character !== ']') { + continue + } + + depth -= 1 + + if (depth === 0) { + return { + color, + text: source.slice(contentStartIndex, index).replaceAll('\\]', ']'), + nextIndex: index + 1 + } + } + } + + return null +} + +/** + * 다음 인라인 색상 토큰 후보를 찾는다. + * @param {string} source - 원본 문자열 + * @param {number} startIndex - 검색 시작 위치 + * @returns {RegExpExecArray|null} 정규식 매치 + */ +const findNextInlineColorStart = (source, startIndex) => { + INLINE_COLOR_START_RE.lastIndex = startIndex + return INLINE_COLOR_START_RE.exec(source) +} + +/** + * 다음 일반 인라인 토큰 후보를 찾는다. + * @param {string} source - 원본 문자열 + * @param {number} startIndex - 검색 시작 위치 + * @returns {RegExpExecArray|null} 정규식 매치 + */ +const findNextInlineMarkdownToken = (source, startIndex) => { + INLINE_MARKDOWN_RE.lastIndex = startIndex + return INLINE_MARKDOWN_RE.exec(source) +} + /** * 인라인 마크다운을 표시 세그먼트로 변환한다. * @param {string} value - 원본 문자열 @@ -158,50 +232,77 @@ export const parseInlineSegments = (value) => { const source = String(value || '') const segments = [] let lastIndex = 0 - INLINE_MARKDOWN_RE.lastIndex = 0 - let match = INLINE_MARKDOWN_RE.exec(source) + let searchIndex = 0 + let colorMatch = findNextInlineColorStart(source, searchIndex) + let match = findNextInlineMarkdownToken(source, searchIndex) - while (match) { - if (match.index > lastIndex) { + while (colorMatch || match) { + const useColorMatch = colorMatch && (!match || colorMatch.index <= match.index) + const activeMatch = useColorMatch ? colorMatch : match + + if (activeMatch.index > lastIndex) { segments.push({ type: 'text', - text: source.slice(lastIndex, match.index) + text: source.slice(lastIndex, activeMatch.index) }) } - if (match[2] && match[3]) { - segments.push({ - type: 'color', - color: match[2], - text: match[3].replaceAll('\\]', ']') - }) - } else if (match[4]) { - segments.push(...parseObsidianMathSegments(match[4])) - } else if (match[5] && match[6]) { + if (useColorMatch) { + const colorToken = readInlineColorToken(source, colorMatch.index, colorMatch[1]) + + if (!colorToken) { + segments.push({ + type: 'text', + text: source.slice(colorMatch.index, colorMatch.index + colorMatch[0].length) + }) + lastIndex = colorMatch.index + colorMatch[0].length + searchIndex = lastIndex + } else { + segments.push({ + type: 'color', + color: colorToken.color, + text: colorToken.text + }) + lastIndex = colorToken.nextIndex + searchIndex = colorToken.nextIndex + } + } else if (match[2]) { + segments.push(...parseObsidianMathSegments(match[2])) + lastIndex = INLINE_MARKDOWN_RE.lastIndex + searchIndex = INLINE_MARKDOWN_RE.lastIndex + } else if (match[3] && match[4]) { segments.push({ type: 'link', - text: match[5], - href: match[6] + text: match[3], + href: match[4] }) - } else if (match[7]) { + lastIndex = INLINE_MARKDOWN_RE.lastIndex + searchIndex = INLINE_MARKDOWN_RE.lastIndex + } else if (match[5]) { segments.push({ type: 'strong', - text: match[7] + text: match[5] }) - } else if (match[8]) { + lastIndex = INLINE_MARKDOWN_RE.lastIndex + searchIndex = INLINE_MARKDOWN_RE.lastIndex + } else if (match[6]) { segments.push({ type: 'code', - text: match[8] + text: match[6] }) - } else if (match[9]) { + lastIndex = INLINE_MARKDOWN_RE.lastIndex + searchIndex = INLINE_MARKDOWN_RE.lastIndex + } else if (match[7]) { segments.push({ type: 'em', - text: match[9] + text: match[7] }) + lastIndex = INLINE_MARKDOWN_RE.lastIndex + searchIndex = INLINE_MARKDOWN_RE.lastIndex } - lastIndex = INLINE_MARKDOWN_RE.lastIndex - match = INLINE_MARKDOWN_RE.exec(source) + colorMatch = findNextInlineColorStart(source, searchIndex) + match = findNextInlineMarkdownToken(source, searchIndex) } if (lastIndex < source.length) { @@ -221,19 +322,19 @@ export const parseInlineSegments = (value) => { */ export const segmentsToInlineHtml = (segments) => segments.map((segment) => { if (segment.type === 'strong') { - return `${escapeHtml(segment.text)}` + return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}` } if (segment.type === 'em') { - return `${escapeHtml(segment.text)}` + return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}` } if (segment.type === 'subscript') { - return `${escapeHtml(segment.text)}` + return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}` } if (segment.type === 'superscript') { - return `${escapeHtml(segment.text)}` + return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}` } if (segment.type === 'code') { diff --git a/package-lock.json b/package-lock.json index 31b8646..de5a81c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.16", + "version": "1.10.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.16", + "version": "1.10.17", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 9591781..63c2919 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.16", + "version": "1.10.17", "private": true, "type": "module", "imports": {