Linting example

The browser DOM serves its purpose—representing complex webpages—very well. But its huge scope and loose structure makes it difficult to make assumptions about. A document model that represents a smaller set of documents can be easier to reason about.

This example implements a simple document linter that finds problems in your document, and makes it easy to fix them.

The first part of this example is a function that, given a document, produces an array of problems found in that document. We'll use the descendants method to easily iterate over all nodes in a document. Depending on the type of node, different types of problems are checked for.

Each problem is represented as an object with a message, a start, and an end, so that they can be displayed and highlighted. The objects may also optionally have a fix method, which can be called (passing the view) to fix the problem.

// Words you probably shouldn't use
const badWords = /\b(obviously|clearly|evidently|simply)\b/ig
// Matches punctuation with a space before it
const badPunc = / ([,\.!?:]) ?/g

function lint(doc) {
  let result = [], lastHeadLevel = null

  function record(msg, from, to, fix) {
    result.push({msg, from, to, fix})
  }

  // For each node in the document
  doc.descendants((node, pos) => {
    if (node.isText) {
      // Scan text nodes for suspicious patterns
      let m
      while (m = badWords.exec(node.text))
        record(`Try not to say '${m[0]}'`,
               pos + m.index, pos + m.index + m[0].length)
      while (m = badPunc.exec(node.text))
        record("Suspicious spacing around punctuation",
               pos + m.index, pos + m.index + m[0].length,
               fixPunc(m[1] + " "))
    } else if (node.type.name == "heading") {
      // Check whether heading levels fit under the current level
      let level = node.attrs.level
      if (lastHeadLevel != null && level > lastHeadLevel + 1)
        record(`Heading too small (${level} under ${lastHeadLevel})`,
               pos + 1, pos + 1 + node.content.size,
               fixHeader(lastHeadLevel + 1))
      lastHeadLevel = level
    } else if (node.type.name == "image" && !node.attrs.alt) {
      // Ensure images have alt text
      record("Image without alt text", pos, pos + 1, addAlt)
    }
  })

  return result
}

The helper utilities that are used to provide fix commands look like this.

function fixPunc(replacement) {
  return function({state, dispatch}) {
    dispatch(state.tr.replaceWith(this.from, this.to,
                                  state.schema.text(replacement)))
  }
}

function fixHeader(level) {
  return function({state, dispatch}) {
    dispatch(state.tr.setNodeMarkup(this.from - 1, null, {level}))
  }
}

function addAlt({state, dispatch}) {
  let alt = prompt("Alt text", "")
  if (alt) {
    let attrs = Object.assign({}, state.doc.nodeAt(this.from).attrs, {alt})
    dispatch(state.tr.setNodeMarkup(this.from, null, attrs))
  }
}

The way the plugin will work is that it'll keep a set of decorations that highlight problems and inserts an icon next to them. CSS is used to position the icon on the right side of the editor, so that it doesn't interfere with the document flow.

import {Decoration, DecorationSet} from "prosemirror-view"

function lintDeco(doc) {
  let decos = []
  lint(doc).forEach(prob => {
    decos.push(Decoration.inline(prob.from, prob.to, {class: "problem"}),
               Decoration.widget(prob.from, lintIcon(prob)))
  })
  return DecorationSet.create(doc, decos)
}

function lintIcon(prob) {
  let icon = document.createElement("div")
  icon.className = "lint-icon"
  icon.title = prob.msg
  icon.problem = prob
  return icon
}

The problem object is stored in the icon DOM nodes, so that event handlers can access them when handling clicks on the node. We'll make a single click on an icon select the annotated region, and a double click run the fix method.

Recomputing the whole set of problems, and recreating the set of decorations, on every change isn't very efficient, so for production code you might want to consider an approach that can incrementally update these. That'd be quite a bit more complex, but definitely doable—the transaction can give you the information you need to figure out what part of the document changed.

import {Plugin, TextSelection} from "prosemirror-state"

let lintPlugin = new Plugin({
  state: {
    init(_, {doc}) { return lintDeco(doc) },
    apply(tr, old) { return tr.docChanged ? lintDeco(tr.doc) : old }
  },
  props: {
    decorations(state) { return this.getState(state) },
    handleClick(view, _, event) {
      if (/lint-icon/.test(event.target.className)) {
        let {from, to} = event.target.problem
        view.dispatch(
          view.state.tr
            .setSelection(TextSelection.create(view.state.doc, from, to))
            .scrollIntoView())
        return true
      }
    },
    handleDoubleClick(view, _, event) {
      if (/lint-icon/.test(event.target.className)) {
        let prob = event.target.problem
        if (prob.fix) {
          prob.fix(view)
          view.focus()
          return true
        }
      }
    }
  }
})