UNPKG

retext-quotes

Version:

retext plugin to check quotes and apostrophes

304 lines (270 loc) 8.74 kB
/** * @import {Punctuation, Root, Sentence, Word} from 'nlcst' * @import {VFile} from 'vfile' */ /** * @typedef Marker * Marker. * @property {Style} style * Whether the marker is `straight` or `smart`. * @property {string} marker * The actual marker. * @property {'apostrophe' | 'close' | 'open' | undefined} type * What the marker seems to be for. * * * @typedef Options * Configuration. * @property {Style | null | undefined} [preferred='smart'] * Style of quotes to use (default: `'smart'`). * @property {ReadonlyArray<string> | null | undefined} [smart=['“”', '‘’']] * List of quotes to see as “smart” (default: `['“”', '‘’']`). * @property {ReadonlyArray<string> | null | undefined} [straight=['"', "'"]] * List of quotes to see as “straight” (default: `['"', "'"]`). * * @typedef {'smart' | 'straight'} Style * Style. */ import {toString} from 'nlcst-to-string' import {SKIP, visit} from 'unist-util-visit' /** @type {Readonly<Options>} */ const emptyOptions = {} /** @type {ReadonlyArray<string>} */ const defaultSmart = ['“”', '‘’'] /** @type {ReadonlyArray<string>} */ const defaultStraight = ['"', "'"] /** * Check quotes and apostrophes. * * ###### Notes * * This plugin knows about apostrophes as well and prefers `'` when * `preferred: 'straight'`, and `’` otherwise. * * The values in `straight` and `smart` can be one or two characters. * When two, the first character determines the opening quote and the second * the closing quote at that level. * When one, both the opening and closing quote are that character. * * The order in which the preferred quotes appear in their respective list * determines which quotes to use at which level of nesting. * So, to prefer `‘’` at the first level of nesting, and `“”` at the second, * pass: `smart: ['‘’', '“”']`. * * If quotes are nested deeper than the given amount of quotes, the markers * wrap around: a third level of nesting when using `smart: ['«»', '‹›']` * should have double guillemets, a fourth single, a fifth double again, etc. * * @param {Readonly<Options> | null | undefined} [options] * Configuration (optional). * @returns * Transform. */ export default function retextQuotes(options) { const settings = options || emptyOptions const preferred = settings.preferred || 'smart' const smart = settings.smart || defaultSmart const straight = settings.straight || defaultStraight /** * Transform. * * @param {Root} tree * Tree. * @param {VFile} file * File. * @returns {undefined} * Nothing. */ return function (tree, file) { // Walk paragraphs first, that way if the stack isn’t closed properly we // can start fresh each paragraph. visit(tree, 'ParagraphNode', function (paragraph) { /** @type {Array<Marker>} */ const stack = [] // eslint-disable-next-line complexity visit(paragraph, 'PunctuationNode', function (node, index, parent) { const actual = toString(node) const style = check(actual, straight, smart) if (!style || !parent || index === undefined) { return } if (actual === "'" || actual === '’' || !style.type) { inferStyle(stack, style, node, index, parent) } // Open stack. if (style.type === 'open') { stack.push(style) } // Calculate preferred style. /** @type {string} */ let expected if (style.type === 'apostrophe') { expected = preferred === 'smart' ? '’' : "'" } else { const markers = preferred === 'smart' ? smart : straight // Loose closing quote, looks like an apostrophe. if (stack.length === 0 && (actual === '’' || actual === "'")) return expected = markers[ (stack.length === 0 ? 0 : stack.length - 1) % markers.length ] if (expected.length > 1) { expected = expected.charAt(style.type === 'open' ? 0 : 1) } } // Close stack. // There could be a case here where opening and closing are mismatched, // like `“‘this”’`. // I think we’ve got the highest chance of removing them one at a time, // but haven’t really checked it. // We’ll see whether the simple solution holds. if (style.type === 'close') { stack.pop() } // Perfect! if (actual === expected) { return } // On to warning… const name = style.type === 'apostrophe' ? style.type : 'quote' const message = file.message( preferred === style.style ? 'Unexpected `' + actual + '` at this level of nesting, expected `' + expected + '`' : 'Unexpected ' + (preferred === 'smart' ? 'straight' : 'smart') + ' ' + name + ' `' + actual + '`, expected `' + expected + '`', { ancestors: [parent, node], place: node.position, source: 'retext-quotes', ruleId: name } ) message.actual = actual message.expected = [expected] message.url = 'https://github.com/retextjs/retext-quotes#readme' }) return SKIP }) } /** * Check whether `straight` or `smart` contains `value`. * * @param {string} value * Quote or apostrophe. * @param {ReadonlyArray<string>} straight * Straight quotes. * @param {ReadonlyArray<string>} smart * Smart quotes. * @returns {Marker | undefined} * Marker. */ function check(value, straight, smart) { return ( contains(value, straight, 'straight') || contains(value, smart, 'smart') ) } /** * Check if the marker is in `markers`. * * @param {string} value * Marker. * @param {ReadonlyArray<string>} markers * Markers. * @param {Style} style * Style. * @returns {Marker | undefined} * Marker. */ function contains(value, markers, style) { let index = -1 while (++index < markers.length) { const marker = markers[index] const both = marker.length > 1 if (marker.charAt(0) === value) { return {marker, style, type: both ? 'open' : undefined} } if (both && marker.charAt(1) === value) { return {marker, style, type: 'close'} } } } /** * Infere the `style` of a quote. * * @param {ReadonlyArray<Marker>} stack * Stack of markers. * @param {Marker} marker * Marker. * @param {Readonly<Punctuation>} node * Node. * @param {number} index * Index of `node` in `parent`. * @param {Readonly<Sentence> | Readonly<Word>} parent * Parent of `node`. * @returns {undefined} * Nothing. */ // eslint-disable-next-line max-params function inferStyle(stack, marker, node, index, parent) { const value = toString(node) const previous = parent.children[index - 1] const next = parent.children[index + 1] // Needed if this is ever externalised. /* c8 ignore next 3 */ if (!node || node.type !== 'PunctuationNode') { return } if (value === "'" || value === '’') { // Apostrophe when in word. if (parent.type === 'WordNode') { marker.type = 'apostrophe' return } if ( previous && (previous.type === 'SourceNode' || previous.type === 'WordNode') ) { const before = toString(previous) // Apostrophe if the previous word ends in `s`, and there’s no open single // quote. // Example: `Mr. Jones' golf clubs` vs. `'Mr. Jones' golf clubs`. marker.type = before.charAt(before.length - 1).toLowerCase() === 's' && open(stack, marker) ? 'apostrophe' : 'close' return } if (next && next.type === 'WordNode') { // Apostrophe if the next word is a decade. marker.type = /^\d\ds$/.test(toString(next)) ? 'apostrophe' : 'open' return } } marker.type = open(stack, marker) ? 'open' : 'close' } /** * @param {ReadonlyArray<Marker>} stack * Stack of markers. * @param {Readonly<Marker>} marker * Marker. * @returns {boolean} * Whether the stack is open. */ function open(stack, marker) { return ( stack.length === 0 || stack[stack.length - 1].marker !== marker.marker ) } }