smartquotes.js 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. // Convert straight quotation marks to typographic ones
  2. //
  3. 'use strict';
  4. var isWhiteSpace = require('../common/utils').isWhiteSpace;
  5. var isPunctChar = require('../common/utils').isPunctChar;
  6. var isMdAsciiPunct = require('../common/utils').isMdAsciiPunct;
  7. var QUOTE_TEST_RE = /['"]/;
  8. var QUOTE_RE = /['"]/g;
  9. var APOSTROPHE = '\u2019'; /* ’ */
  10. function replaceAt(str, index, ch) {
  11. return str.substr(0, index) + ch + str.substr(index + 1);
  12. }
  13. function process_inlines(tokens, state) {
  14. var i, token, text, t, pos, max, thisLevel, item, lastChar, nextChar,
  15. isLastPunctChar, isNextPunctChar, isLastWhiteSpace, isNextWhiteSpace,
  16. canOpen, canClose, j, isSingle, stack, openQuote, closeQuote;
  17. stack = [];
  18. for (i = 0; i < tokens.length; i++) {
  19. token = tokens[i];
  20. thisLevel = tokens[i].level;
  21. for (j = stack.length - 1; j >= 0; j--) {
  22. if (stack[j].level <= thisLevel) { break; }
  23. }
  24. stack.length = j + 1;
  25. if (token.type !== 'text') { continue; }
  26. text = token.content;
  27. pos = 0;
  28. max = text.length;
  29. /*eslint no-labels:0,block-scoped-var:0*/
  30. OUTER:
  31. while (pos < max) {
  32. QUOTE_RE.lastIndex = pos;
  33. t = QUOTE_RE.exec(text);
  34. if (!t) { break; }
  35. canOpen = canClose = true;
  36. pos = t.index + 1;
  37. isSingle = (t[0] === "'");
  38. // Find previous character,
  39. // default to space if it's the beginning of the line
  40. //
  41. lastChar = 0x20;
  42. if (t.index - 1 >= 0) {
  43. lastChar = text.charCodeAt(t.index - 1);
  44. } else {
  45. for (j = i - 1; j >= 0; j--) {
  46. if (tokens[j].type === 'softbreak' || tokens[j].type === 'hardbreak') break; // lastChar defaults to 0x20
  47. if (!tokens[j].content) continue; // should skip all tokens except 'text', 'html_inline' or 'code_inline'
  48. lastChar = tokens[j].content.charCodeAt(tokens[j].content.length - 1);
  49. break;
  50. }
  51. }
  52. // Find next character,
  53. // default to space if it's the end of the line
  54. //
  55. nextChar = 0x20;
  56. if (pos < max) {
  57. nextChar = text.charCodeAt(pos);
  58. } else {
  59. for (j = i + 1; j < tokens.length; j++) {
  60. if (tokens[j].type === 'softbreak' || tokens[j].type === 'hardbreak') break; // nextChar defaults to 0x20
  61. if (!tokens[j].content) continue; // should skip all tokens except 'text', 'html_inline' or 'code_inline'
  62. nextChar = tokens[j].content.charCodeAt(0);
  63. break;
  64. }
  65. }
  66. isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar));
  67. isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar));
  68. isLastWhiteSpace = isWhiteSpace(lastChar);
  69. isNextWhiteSpace = isWhiteSpace(nextChar);
  70. if (isNextWhiteSpace) {
  71. canOpen = false;
  72. } else if (isNextPunctChar) {
  73. if (!(isLastWhiteSpace || isLastPunctChar)) {
  74. canOpen = false;
  75. }
  76. }
  77. if (isLastWhiteSpace) {
  78. canClose = false;
  79. } else if (isLastPunctChar) {
  80. if (!(isNextWhiteSpace || isNextPunctChar)) {
  81. canClose = false;
  82. }
  83. }
  84. if (nextChar === 0x22 /* " */ && t[0] === '"') {
  85. if (lastChar >= 0x30 /* 0 */ && lastChar <= 0x39 /* 9 */) {
  86. // special case: 1"" - count first quote as an inch
  87. canClose = canOpen = false;
  88. }
  89. }
  90. if (canOpen && canClose) {
  91. // Replace quotes in the middle of punctuation sequence, but not
  92. // in the middle of the words, i.e.:
  93. //
  94. // 1. foo " bar " baz - not replaced
  95. // 2. foo-"-bar-"-baz - replaced
  96. // 3. foo"bar"baz - not replaced
  97. //
  98. canOpen = isLastPunctChar;
  99. canClose = isNextPunctChar;
  100. }
  101. if (!canOpen && !canClose) {
  102. // middle of word
  103. if (isSingle) {
  104. token.content = replaceAt(token.content, t.index, APOSTROPHE);
  105. }
  106. continue;
  107. }
  108. if (canClose) {
  109. // this could be a closing quote, rewind the stack to get a match
  110. for (j = stack.length - 1; j >= 0; j--) {
  111. item = stack[j];
  112. if (stack[j].level < thisLevel) { break; }
  113. if (item.single === isSingle && stack[j].level === thisLevel) {
  114. item = stack[j];
  115. if (isSingle) {
  116. openQuote = state.md.options.quotes[2];
  117. closeQuote = state.md.options.quotes[3];
  118. } else {
  119. openQuote = state.md.options.quotes[0];
  120. closeQuote = state.md.options.quotes[1];
  121. }
  122. // replace token.content *before* tokens[item.token].content,
  123. // because, if they are pointing at the same token, replaceAt
  124. // could mess up indices when quote length != 1
  125. token.content = replaceAt(token.content, t.index, closeQuote);
  126. tokens[item.token].content = replaceAt(
  127. tokens[item.token].content, item.pos, openQuote);
  128. pos += closeQuote.length - 1;
  129. if (item.token === i) { pos += openQuote.length - 1; }
  130. text = token.content;
  131. max = text.length;
  132. stack.length = j;
  133. continue OUTER;
  134. }
  135. }
  136. }
  137. if (canOpen) {
  138. stack.push({
  139. token: i,
  140. pos: t.index,
  141. single: isSingle,
  142. level: thisLevel
  143. });
  144. } else if (canClose && isSingle) {
  145. token.content = replaceAt(token.content, t.index, APOSTROPHE);
  146. }
  147. }
  148. }
  149. }
  150. module.exports = function smartquotes(state) {
  151. /*eslint max-depth:0*/
  152. var blkIdx;
  153. if (!state.md.options.typographer) { return; }
  154. for (blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) {
  155. if (state.tokens[blkIdx].type !== 'inline' ||
  156. !QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) {
  157. continue;
  158. }
  159. process_inlines(state.tokens[blkIdx].children, state);
  160. }
  161. };