|
|
|
// 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)
|
|
|
|
}
|
|
|
|
}
|