smartquotes.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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].type !== 'text') continue;
  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].type !== 'text') continue;
  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. // treat this as the middle of the word
  92. canOpen = false;
  93. canClose = isNextPunctChar;
  94. }
  95. if (!canOpen && !canClose) {
  96. // middle of word
  97. if (isSingle) {
  98. token.content = replaceAt(token.content, t.index, APOSTROPHE);
  99. }
  100. continue;
  101. }
  102. if (canClose) {
  103. // this could be a closing quote, rewind the stack to get a match
  104. for (j = stack.length - 1; j >= 0; j--) {
  105. item = stack[j];
  106. if (stack[j].level < thisLevel) { break; }
  107. if (item.single === isSingle && stack[j].level === thisLevel) {
  108. item = stack[j];
  109. if (isSingle) {
  110. openQuote = state.md.options.quotes[2];
  111. closeQuote = state.md.options.quotes[3];
  112. } else {
  113. openQuote = state.md.options.quotes[0];
  114. closeQuote = state.md.options.quotes[1];
  115. }
  116. // replace token.content *before* tokens[item.token].content,
  117. // because, if they are pointing at the same token, replaceAt
  118. // could mess up indices when quote length != 1
  119. token.content = replaceAt(token.content, t.index, closeQuote);
  120. tokens[item.token].content = replaceAt(
  121. tokens[item.token].content, item.pos, openQuote);
  122. pos += closeQuote.length - 1;
  123. if (item.token === i) { pos += openQuote.length - 1; }
  124. text = token.content;
  125. max = text.length;
  126. stack.length = j;
  127. continue OUTER;
  128. }
  129. }
  130. }
  131. if (canOpen) {
  132. stack.push({
  133. token: i,
  134. pos: t.index,
  135. single: isSingle,
  136. level: thisLevel
  137. });
  138. } else if (canClose && isSingle) {
  139. token.content = replaceAt(token.content, t.index, APOSTROPHE);
  140. }
  141. }
  142. }
  143. }
  144. module.exports = function smartquotes(state) {
  145. /*eslint max-depth:0*/
  146. var blkIdx;
  147. if (!state.md.options.typographer) { return; }
  148. for (blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) {
  149. if (state.tokens[blkIdx].type !== 'inline' ||
  150. !QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) {
  151. continue;
  152. }
  153. process_inlines(state.tokens[blkIdx].children, state);
  154. }
  155. };