export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText' function escapeHtml(value = '') { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export function normalizeHiddenTextValue(value = '') { return String(value ?? '').replace(/\r\n?/g, ' ') } export function escapeHiddenTextSegment(value = '', closingChar) { const normalized = normalizeHiddenTextValue(value) const closingCharPattern = new RegExp(`\\${closingChar}`, 'g') return normalized .replace(/\\/g, '\\\\') .replace(closingCharPattern, `\\${closingChar}`) } export function serializeHiddenTextSyntax(displayed = '', hidden = '') { const safeDisplayed = escapeHiddenTextSegment(displayed, ')') const safeHidden = escapeHiddenTextSegment(hidden, '}') return `(${safeDisplayed}){${safeHidden}}` } function readHiddenTextSegment(text = '', start = 0, closingChar = ')') { let index = start let value = '' while (index < text.length) { const char = text[index] if (char === '\\') { const nextChar = text[index + 1] if (nextChar === undefined) return null value += nextChar index += 2 continue } if (char === '\n' || char === '\r') return null if (char === closingChar) { return { value, end: index + 1, } } value += char index += 1 } return null } export function parseHiddenTextAt(text = '', start = 0) { if (text[start] !== '(') return null const displayed = readHiddenTextSegment(text, start + 1, ')') if (!displayed) return null if (text[displayed.end] !== '{') return null const hidden = readHiddenTextSegment(text, displayed.end + 1, '}') if (!hidden) return null return { start, end: hidden.end, displayed: displayed.value, hidden: hidden.value, raw: text.slice(start, hidden.end), } } export function extractHiddenTextMatches(text = '') { const matches = [] let index = 0 while (index < text.length) { const match = parseHiddenTextAt(text, index) if (match) { matches.push(match) index = match.end continue } index += 1 } return matches } export function splitTextWithHiddenSyntax(text = '') { const matches = extractHiddenTextMatches(text) if (matches.length === 0) return null const segments = [] let cursor = 0 for (const match of matches) { if (match.start > cursor) { segments.push({ type: 'text', value: text.slice(cursor, match.start), }) } segments.push({ type: HIDDEN_TEXT_NODE_TYPE, displayed: match.displayed, hidden: match.hidden, }) cursor = match.end } if (cursor < text.length) { segments.push({ type: 'text', value: text.slice(cursor), }) } return segments.filter((segment) => segment.type !== 'text' || segment.value) } export function renderHiddenTextPreviewHtml(displayed = '', hidden = '') { const summary = escapeHtml(displayed || '未命名文本') return [ ``, `${summary}`, '', ].join('') } export function hiddenTextMarkdownItPlugin(md) { md.inline.ruler.before('emphasis', 'hidden_text', (state, silent) => { const match = parseHiddenTextAt(state.src, state.pos) if (!match) return false if (!silent) { const token = state.push('hidden_text', '', 0) token.meta = { displayed: match.displayed, hidden: match.hidden, } } state.pos = match.end return true }) md.renderer.rules.hidden_text = (tokens, index) => { const meta = tokens[index]?.meta || {} return renderHiddenTextPreviewHtml(meta.displayed, meta.hidden) } }