// Convert straight quotation marks to typographic ones // import { isWhiteSpace, isPunctChar, isMdAsciiPunct } from '../common/utils.mjs'; const QUOTE_TEST_RE = /['"]/; const QUOTE_RE = /['"]/g; const APOSTROPHE = '\u2019'; /* ’ */ function replaceAt(str, index, ch) { return str.slice(0, index) + ch + str.slice(index + 1); } function process_inlines(tokens, state) { let j; const stack = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const thisLevel = tokens[i].level; for (j = stack.length - 1; j >= 0; j--) { if (stack[j].level <= thisLevel) { break; } } stack.length = j + 1; if (token.type !== 'text') { continue; } let text = token.content; let pos = 0; let max = text.length; /*eslint no-labels:0,block-scoped-var:0*/ OUTER: while (pos < max) { QUOTE_RE.lastIndex = pos; const t = QUOTE_RE.exec(text); if (!t) { break; } let canOpen = true; let canClose = true; pos = t.index + 1; const isSingle = (t[0] === "'"); // Find previous character, // default to space if it's the beginning of the line // let lastChar = 0x20; if (t.index - 1 >= 0) { lastChar = text.charCodeAt(t.index - 1); } else { for (j = i - 1; j >= 0; j--) { if (tokens[j].type === 'softbreak' || tokens[j].type === 'hardbreak') break; // lastChar defaults to 0x20 if (!tokens[j].content) continue; // should skip all tokens except 'text', 'html_inline' or 'code_inline' lastChar = tokens[j].content.charCodeAt(tokens[j].content.length - 1); break; } } // Find next character, // default to space if it's the end of the line // let nextChar = 0x20; if (pos < max) { nextChar = text.charCodeAt(pos); } else { for (j = i + 1; j < tokens.length; j++) { if (tokens[j].type === 'softbreak' || tokens[j].type === 'hardbreak') break; // nextChar defaults to 0x20 if (!tokens[j].content) continue; // should skip all tokens except 'text', 'html_inline' or 'code_inline' nextChar = tokens[j].content.charCodeAt(0); break; } } const isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); const isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); const isLastWhiteSpace = isWhiteSpace(lastChar); const isNextWhiteSpace = isWhiteSpace(nextChar); if (isNextWhiteSpace) { canOpen = false; } else if (isNextPunctChar) { if (!(isLastWhiteSpace || isLastPunctChar)) { canOpen = false; } } if (isLastWhiteSpace) { canClose = false; } else if (isLastPunctChar) { if (!(isNextWhiteSpace || isNextPunctChar)) { canClose = false; } } if (nextChar === 0x22 /* " */ && t[0] === '"') { if (lastChar >= 0x30 /* 0 */ && lastChar <= 0x39 /* 9 */) { // special case: 1"" - count first quote as an inch canClose = canOpen = false; } } if (canOpen && canClose) { // Replace quotes in the middle of punctuation sequence, but not // in the middle of the words, i.e.: // // 1. foo " bar " baz - not replaced // 2. foo-"-bar-"-baz - replaced // 3. foo"bar"baz - not replaced // canOpen = isLastPunctChar; canClose = isNextPunctChar; } if (!canOpen && !canClose) { // middle of word if (isSingle) { token.content = replaceAt(token.content, t.index, APOSTROPHE); } continue; } if (canClose) { // this could be a closing quote, rewind the stack to get a match for (j = stack.length - 1; j >= 0; j--) { let item = stack[j]; if (stack[j].level < thisLevel) { break; } if (item.single === isSingle && stack[j].level === thisLevel) { item = stack[j]; let openQuote; let closeQuote; if (isSingle) { openQuote = state.md.options.quotes[2]; closeQuote = state.md.options.quotes[3]; } else { openQuote = state.md.options.quotes[0]; closeQuote = state.md.options.quotes[1]; } // replace token.content *before* tokens[item.token].content, // because, if they are pointing at the same token, replaceAt // could mess up indices when quote length != 1 token.content = replaceAt(token.content, t.index, closeQuote); tokens[item.token].content = replaceAt( tokens[item.token].content, item.pos, openQuote); pos += closeQuote.length - 1; if (item.token === i) { pos += openQuote.length - 1; } text = token.content; max = text.length; stack.length = j; continue OUTER; } } } if (canOpen) { stack.push({ token: i, pos: t.index, single: isSingle, level: thisLevel }); } else if (canClose && isSingle) { token.content = replaceAt(token.content, t.index, APOSTROPHE); } } } } export default function smartquotes(state) { /*eslint max-depth:0*/ if (!state.md.options.typographer) { return; } for (let blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { if (state.tokens[blkIdx].type !== 'inline' || !QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) { continue; } process_inlines(state.tokens[blkIdx].children, state); } }