/** @type {RegExp} 이미지 마크다운 한 줄 */ const IMAGE_MARKDOWN_LINE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)(?:\{width=(regular|wide|full)\})?$/ /** @type {RegExp} 이미지 파일 확장자 */ const IMAGE_URL_EXTENSION_RE = /\.(?:jpe?g|png|webp|gif|avif|svg)(?:$|[?#])/i /** * 캡션 문자열 이스케이프 해제 * @param {string} value - 이스케이프된 캡션 * @returns {string} 캡션 */ const unescapeImageCaption = (value) => String(value || '').replace(/\\"/g, '"') /** * 캡션 문자열 이스케이프 * @param {string} value - 캡션 * @returns {string} 이스케이프된 캡션 */ const escapeImageCaption = (value) => String(value || '').replace(/"/g, '\\"') /** * URL에서 기본 대체 텍스트(파일명) 추출 * @param {string} url - 이미지 URL * @returns {string} 파일명 기반 라벨 */ /** * 파일명·대괄호 라벨 비교용 정규화 * @param {string} value - 원본 문자열 * @returns {string} 정규화된 문자열 */ const normalizeFilenameLabel = (value) => { const raw = String(value || '').trim() if (!raw) { return '' } try { return decodeURIComponent(raw) } catch { return raw } } export const getImageDefaultAltLabel = (url) => { const raw = String(url || '').trim() if (!raw) { return '' } try { const pathname = new URL(raw, 'https://sori.studio').pathname return decodeURIComponent(pathname.split('/').filter(Boolean).pop() || '') } catch { const withoutQuery = raw.split('?')[0].split('#')[0] return decodeURIComponent(withoutQuery.split('/').filter(Boolean).pop() || '') } } /** * 이미지 파일 URL인지 확인한다. * @param {string} url - 검사할 URL * @returns {boolean} 이미지 URL 여부 */ export const isImageUrl = (url) => { const raw = String(url || '').trim() if (!raw) { return false } try { return IMAGE_URL_EXTENSION_RE.test(new URL(raw, 'https://sori.studio').pathname) } catch { return IMAGE_URL_EXTENSION_RE.test(raw) } } /** * 이미지 마크다운 한 줄 파싱 * @param {string} line - 마크다운 줄 * @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null} */ export const parseImageMarkdownLine = (line) => { const match = String(line || '').trim().match(IMAGE_MARKDOWN_LINE_RE) if (!match) { return null } const url = match[2] || '' const altBracket = (match[1] || '').trim() const quotedCaption = unescapeImageCaption(match[3]) const filenameAlt = getImageDefaultAltLabel(url) /** 레거시 `![파일명](url)` 또는 따옴표 캡션이 URL 파일명과 같을 때 파일명 캡션 모드 */ const useAlt = (altBracket !== '' && normalizeFilenameLabel(altBracket) === normalizeFilenameLabel(filenameAlt)) || (quotedCaption !== '' && altBracket === '' && normalizeFilenameLabel(quotedCaption) === normalizeFilenameLabel(filenameAlt)) return { url, /** `![](url "캡션")` — 파일명 캡션 모드면 편집 필드에 파일명 반영 */ caption: quotedCaption || (useAlt ? filenameAlt : ''), /** 레거시 `![표시문구](url)` — 캡션 따옴표 없을 때 미리보기용 */ legacyBracketLabel: !quotedCaption && altBracket && !useAlt ? altBracket : '', width: match[4] || 'regular', useAlt } } /** * 이미지 마크다운 한 줄 생성 * @param {{ url: string, caption?: string, width?: string, useAlt?: boolean }} image - 이미지 정보 * @returns {string} 마크다운 줄 */ export const serializeImageMarkdown = (image) => { const url = String(image.url || '').trim() if (!url) { return '' } const filename = getImageDefaultAltLabel(url) let caption = String(image.caption ?? '').trim() if (image.useAlt === true && !caption) { caption = filename } const titlePart = caption ? ` "${escapeImageCaption(caption)}"` : '' const width = image.width && image.width !== 'regular' ? `{width=${image.width}}` : '' return `![](${url}${titlePart})${width}` } /** * 공개 렌더용 img alt 텍스트 * @param {{ url?: string, useAlt?: boolean }} image - 이미지 정보 * @returns {string} alt 속성값 */ export const getImageAltAttribute = () => '' /** * 공개 렌더용 캡션(표시용 figcaption) * @param {{ caption?: string }} image - 이미지 정보 * @returns {string} 캡션 */ export const getImageCaption = (image) => { const caption = String(image?.caption || '').trim() if (caption) { return caption } if (image?.useAlt) { return getImageDefaultAltLabel(image.url) } return String(image?.legacyBracketLabel || '').trim() } /** * 표시용 캡션(단일·갤러리 공통) * @param {Object} image - 이미지 블록 데이터 * @returns {string} figcaption 텍스트 */ export const getImageDisplayCaption = (image) => getImageCaption(image)