218 lines
6.1 KiB
JavaScript
218 lines
6.1 KiB
JavaScript
/** @type {RegExp} 이미지 마크다운 기본 줄 */
|
|
const IMAGE_MARKDOWN_BASE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)((?:\{[^{}]+\})*)$/
|
|
/** @type {RegExp} 이미지 옵션 토큰 */
|
|
const IMAGE_MARKDOWN_OPTION_RE = /\{([^{}=]+)=([^{}]+)\}/g
|
|
/** @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, '\\"')
|
|
|
|
/**
|
|
* 이미지 픽셀 너비를 정규화한다.
|
|
* @param {unknown} value - 입력 너비
|
|
* @returns {number|null} 정규화된 너비
|
|
*/
|
|
export const normalizeImagePixelWidth = (value) => {
|
|
const width = Math.round(Number(value) || 0)
|
|
|
|
if (!width) {
|
|
return null
|
|
}
|
|
|
|
return Math.min(Math.max(width, 120), 1600)
|
|
}
|
|
|
|
/**
|
|
* 이미지 마크다운 옵션을 파싱한다.
|
|
* @param {string} rawOptions - 옵션 문자열
|
|
* @returns {{ width: string, pixelWidth: number|null }} 이미지 옵션
|
|
*/
|
|
const parseImageMarkdownOptions = (rawOptions) => {
|
|
const options = {
|
|
width: 'regular',
|
|
pixelWidth: null
|
|
}
|
|
|
|
for (const match of String(rawOptions || '').matchAll(IMAGE_MARKDOWN_OPTION_RE)) {
|
|
const key = String(match[1] || '').trim()
|
|
const value = String(match[2] || '').trim()
|
|
|
|
if (key === 'width' && ['regular', 'wide', 'full'].includes(value)) {
|
|
options.width = value
|
|
}
|
|
|
|
if (key === 'size') {
|
|
options.pixelWidth = normalizeImagePixelWidth(value)
|
|
}
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
/**
|
|
* 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, pixelWidth: number|null, caption: string, useAlt: boolean }|null}
|
|
*/
|
|
export const parseImageMarkdownLine = (line) => {
|
|
const match = String(line || '').trim().match(IMAGE_MARKDOWN_BASE_RE)
|
|
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
const url = match[2] || ''
|
|
const options = parseImageMarkdownOptions(match[4])
|
|
const altBracket = (match[1] || '').trim()
|
|
const quotedCaption = unescapeImageCaption(match[3])
|
|
const filenameAlt = getImageDefaultAltLabel(url)
|
|
/** 레거시 `` 또는 따옴표 캡션이 URL 파일명과 같을 때 파일명 캡션 모드 */
|
|
const useAlt = (altBracket !== ''
|
|
&& normalizeFilenameLabel(altBracket) === normalizeFilenameLabel(filenameAlt))
|
|
|| (quotedCaption !== ''
|
|
&& altBracket === ''
|
|
&& normalizeFilenameLabel(quotedCaption) === normalizeFilenameLabel(filenameAlt))
|
|
|
|
return {
|
|
url,
|
|
/** `` — 파일명 캡션 모드면 편집 필드에 파일명 반영 */
|
|
caption: quotedCaption || (useAlt ? filenameAlt : ''),
|
|
/** 레거시 `` — 캡션 따옴표 없을 때 미리보기용 */
|
|
legacyBracketLabel: !quotedCaption && altBracket && !useAlt ? altBracket : '',
|
|
width: options.width,
|
|
pixelWidth: options.pixelWidth,
|
|
useAlt
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이미지 마크다운 한 줄 생성
|
|
* @param {{ url: string, caption?: string, width?: string, pixelWidth?: number|null, 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}}` : ''
|
|
const pixelWidth = normalizeImagePixelWidth(image.pixelWidth)
|
|
const size = pixelWidth ? `{size=${pixelWidth}}` : ''
|
|
|
|
return `${width}${size}`
|
|
}
|
|
|
|
/**
|
|
* 공개 렌더용 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)
|