Cómo hice ingeniería inversa del editor de Hemingway, una popular aplicación de escritura, y construí la mía propia en una playa en Tailandia

He estado usando la aplicación Hemingway para intentar mejorar mis publicaciones. Al mismo tiempo, he estado tratando de encontrar ideas para pequeños proyectos. Se me ocurrió la idea de integrar un editor de estilo Hemingway en un editor de rebajas. ¡Así que necesitaba averiguar cómo funcionaba Hemingway!

Conseguir la lógica

No tenía idea de cómo funcionaba la aplicación cuando comencé. Podría haber enviado el texto a un servidor para calcular la complejidad de la escritura, pero esperaba que se calculara del lado del cliente.

Abrir herramientas de desarrollador en Chrome (Control + Shift + I o F12 en Windows / Linux, Comando + Opción + I en Mac) y navegar a Fuentes proporcionó las respuestas . Allí encontré el archivo que estaba buscando: hemingway3-web.js.

Este código está en una forma reducida, lo cual es un dolor de leer y comprender. Para resolver esto, copié el archivo en VS Code y formateé el documento ( Control + Shift + I para VS Code). Esto cambia un archivo de 3 líneas a un archivo de 4859 líneas con todo bien formateado.

Explorando el Código

Comencé a buscar en el archivo cualquier cosa que pudiera encontrarle sentido. El inicio del archivo contenía expresiones de función invocadas inmediatamente. Tenía poca idea de lo que estaba pasando.

!function(e) { function t(r) { if (n[r]) return n[r].exports; var o = n[r] = { exports: {}, id: r, loaded: !1 }; ...

Esto continuó durante unas 200 líneas antes de que decidiera que probablemente estaba leyendo el código para ejecutar la página (¿Reaccionar?). Comencé a hojear el resto del código hasta que encontré algo que podía entender. (Me perdí mucho que luego encontraría al buscar llamadas a funciones y mirar la definición de la función).

¡El primer fragmento de código que entendí fue hasta la línea 3496!

getTokens: function(e) { var t = this.getAdverbs(e), n = this.getQualifiers(e), r = this.getPassiveVoices(e), o = this.getComplexWords(e); return [].concat(t, n, r, o).sort(function(e, t) { return e.startIndex - t.startIndex }) }

Y sorprendentemente, todas estas funciones se definieron a continuación. Ahora sabía cómo la aplicación definía adverbios, calificadores, voz pasiva y palabras complejas. Algunos de ellos son muy sencillos. La aplicación compara cada palabra con listas de calificadores, palabras complejas y frases de voz pasiva. this.getAdverbs filtra las palabras en función de si terminan en 'ly' y luego comprueba si están en la lista de palabras que no son adverbio y terminan en 'ly'.

El siguiente código útil fue la implementación de resaltar palabras u oraciones. En este código hay una línea:

e.highlight.hardSentences += h

'hardSentences' era algo que podía entender, algo con significado. Luego busqué el archivo hardSentencesy obtuve 13 coincidencias. Esto conduce a una línea que calcula las estadísticas de legibilidad:

n.stats.readability === i.default.readability.hard && (e.hardSentences += 1), n.stats.readability === i.default.readability.veryHard && (e.veryHardSentences += 1)

Ahora sabía que había un readabilityparámetro en ambos statsy i.default. Buscando en el archivo, obtuve 40 coincidencias. Una de esas coincidencias fue una getReadabilityStylefunción, donde calificaron su escritura.

Hay tres niveles: normal, difícil y muy difícil.

t = e.words; n = e.readingLevel; return t = 10 && n = 14 ? i.default.readability.veryHard : i.default.readability.normal;

"Normal" es menos de 14 palabras, "difícil" es de 10 a 14 palabras y "muy difícil" es más de 14 palabras.

Ahora para averiguar cómo calcular el nivel de lectura.

Pasé un tiempo aquí tratando de encontrar alguna noción de cómo calcular el nivel de lectura. Lo encontré 4 líneas arriba de la getReadabilityStylefunción.

e = letters in paragraph; t = words in paragraph; n = sentences in paragraph; getReadingLevel: function(e, t, n) { if (0 === t 0 === n) return 0; var r = Math.round(4.71 * (e / t) + 0.5 * (t / n) - 21.43); return r <= 0 ? 0 : r; }

Eso significa que su puntuación es 4.71 * longitud promedio de palabra + 0.5 * longitud promedio de oración -21.43. Eso es. Así es como Hemingway califica cada una de tus frases.

Otras cosas interesantes que encontré

  • El comentario destacado (información sobre su escritura en el lado derecho) es una gran declaración de cambio. Las declaraciones ternarias se utilizan para cambiar la respuesta según lo bien que haya escrito.
  • La calificación sube a 16 antes de que se clasifique como nivel de "Postgrado".

Que voy a hacer con esto

Estoy planeando hacer un sitio web básico y aplicar lo que he aprendido al deconstruir la aplicación Hemingway. Nada lujoso, más como un ejercicio para implementar algo de lógica. Ya he creado una vista previa de Markdown, por lo que también podría intentar crear una aplicación de escritura con el sistema de resaltado y puntuación.

Creación de mi propia aplicación Hemingway

Habiendo averiguado cómo funciona la aplicación Hemingway, decidí implementar lo que había aprendido para hacer una versión mucho más simplificada.

Quería asegurarme de mantenerlo básico, centrándome en la lógica más que en el estilo. Elegí ir con un cuadro de entrada de cuadro de texto simple.

Desafíos

1. Cómo asegurar el desempeño. Volver a escanear todo el documento con cada pulsación de tecla puede resultar muy costoso desde el punto de vista informático. Esto podría resultar en un bloqueo de UX que obviamente no es lo que queremos.

2. Cómo dividir el texto en párrafos, oraciones y palabras para resaltar.

Soluciones posibles

  • Solo vuelva a escanear los párrafos que cambian. Haga esto contando el número de párrafos y comparándolo con el documento antes del cambio. Use esto para encontrar el párrafo que ha cambiado o el nuevo párrafo y solo escanee ese.
  • Tener un botón para escanear el documento. Esto reduce enormemente las llamadas de la función de escaneo.

2. Utilice lo que aprendí de Hemingway: cada párrafo es un

y las frases o palabras que necesitan resaltar se envuelven en un interno con la clase necesaria.

Construyendo la aplicación

Recientemente, leí muchos artículos sobre la creación de un producto mínimo viable (MVP), así que decidí que ejecutaría este pequeño proyecto de la misma manera. Esto significaba mantener todo simple. Decidí ir con un cuadro de entrada, un botón para escanear y un área de salida.

This was all very easy to set up in my index.html file.

 Fake Hemingway 

Fake Hemingway

Test Me

Now to start on the interesting part. Now to get the Javascript working.

The first thing to do was to render the text from the text box into the output area. This involves finding the input text and setting the output’s inner html to that text.

function format() { let inputArea = document.getElementById(“text-area”); let text = inputArea.value; let outputArea = document.getElementById(“output”); outputArea.innerHTML = text; }

Next is getting the text split into paragraphs. This is accomplished by splitting the text by ‘\n’ and putting each of these into a

tag. To do this we can map over the array of paragraphs, putting them in between

tags. Using template strings makes doing this very easy.

let paragraphs = text.split(“\n”); let inParagraphs = paragraphs.map(paragraph => `

${paragraph}

`); outputArea.innerHTML = inParagraphs.join(“ “);

Whilst I was working though that, I was becoming annoyed having to copy and paste the test text into the text box. To solve this, I implemented an Immediately Invoked Function Expression (IIFE) to populate the text box when the web page renders.

(function start() { let inputArea = document.getElementById(“text-area”); let text = `The app highlights lengthy, …. compose something new.`; inputArea.value = text; })();

Now the text box was pre-populated with the test text whenever you load or refresh the web page. Much simpler.

Highlighting

Now that I was rendering the text well and I was testing on a consistent text, I had to work on the highlighting. The first type of highlighting I decided to tackle was the hard and very hard sentence highlighting.

The first stage of this is to loop over every paragraph and split them into an array of sentences. I did this using a `split()` function, splitting on every full stop with a space after it.

let sentences = paragraph.split(‘. ’);

From Heminway I knew that I needed to calculate the number of words and level of each of the sentences. The level of the sentence is dependant on the average length of words and the average words per sentence. Here is how I calculated the number of words and the total words per sentence.

let words = sentence.split(“ “).length; let letters = sentence.split(“ “).join(“”).length;

Using these numbers, I could use the equation that I found in the Hemingway app.

let level = Math.round(4.71 * (letters / words) + 0.5 * words / sentences — 21.43);

With the level and number of words for each of the sentences, set their difficulty level.

if (words = 10 && level < 14) { return `${sentence}`; } else if (level >= 14) { return `${sentence}`; } else { return sentence; }

This code says that if a sentence is longer than 14 words and has a level of 10 to 14 then its hard, if its longer than 14 words and has a level of 14 or up then its very hard. I used template strings again but include a class in the span tags. This is how I’m going to define the highlighting.

The CSS file is really simple; it just has each of the classes (adverb, passive, hardSentence) and sets their background colour. I took the exact colours from the Hemingway app.

Once the sentences have been returned, I join them all together to make each of the paragraphs.

At this point, I realised that there were a few problems in my code.

  • There were no full stops. When I split the paragraphs into sentences, I had removed all of the full stops.
  • The numbers of letters in the sentence included the commas, dashes, colons and semi-colons.

My first solution was very primitive but it worked. I used split(‘symbol’) and join(‘’) to remove the punctuation and then appended ‘.’ onto the end. Whist it worked, I searched for a better solution. Although I don’t have much experience using regex, I knew that it would be the best solution. After some Googling I found a much more elegant solution.

let cleanSentence = sent.replace(/[^a-z0–9. ]/gi, “”) + “.”;

With this done, I had a partially working product.

The next thing I decided to tackle was the adverbs. To find an adverb, Hemingway just finds words that end in ‘ly’ and then checks that it isn’t on a list of non-adverb ‘ly’ words. It would be bad if ‘apply’ or ‘Italy’ were tagged as adverbs.

To find these words, I took the sentences and split them into an arary of words. I mapped over this array and used an IF statement.

if(word.match(/ly$/) &&, !lyWords[word] ){ return `${word}`; } else { return word };

Whist this worked most of the time, I found a few exceptions. If a word was followed by a punctuation mark then it didn’t match ending with ‘ly’. For example, “The crocodile glided elegantly; it’s prey unaware” would have the word ‘elegantly;’ in the array. To solve this I reused the .replace(/^a-z0-9. ]/gi,””) functionality to clean each of the words.

Another exception was if the word was capitalised, which was easily solved by calling toLowerCase()on the string.

Now I had a result that worked with adverbs and highlighting individual words. I then implemented a very similar method for complex and qualifying words. That was when I realised that I was no longer just looking for individual words, I was looking for phrases. I had to change my approach from checking if each word was in the list to seeing if the sentence contained each of the phrases.

To do this I used the .indexOf() function on the sentences. If there was an index of the word or phrase, I inserted an opening span tag at that index and then the closing span tag after the key length.

let qualifiers = getQualifyingWords(); let wordList = Object.keys(qualifiers); wordList.forEach(key => { let index = sentence.toLowerCase().indexOf(key); if (index >= 0) { sentence = sentence.slice(0, index) + ‘’ + sentence.slice(index, index + key.length) + “” + sentence.slice(index + key.length); } });

With that working, it’s starting to look more and more like the Hemingway editor.

The last piece of the highlighting puzzle to implement was the passive voice. Hemingway used a 30 line function to find all of the passive phrases. I chose to use most of the logic that Hemingway implemented, but order the process differently. They looked to find any words that were in a list (is, are, was, were, be, been, being) and then checked whether the next word ended in ‘ed’.

I looped though each of the words in a sentence and checked if they ended in ‘ed’. For every ‘ed’ word I found, I checked whether the previous word was in the list of pre-words. This seemed much simpler, but may be less performant.

With that working I had an app that highlighted everything I wanted. This is my MVP.

Then I hit a problem

As I was writing this post I realised that there were two huge bugs in my code.

// from getQualifier and getComplex let index = sentence.toLowerCase().indexOf(key); // from getPassive let index = words.indexOf(match);

These will only ever find the first instance of the key or match. Here is an example of the results this code will produce.

‘Perhaps’ and ‘been marked’ should have been highlighted twice each but they aren’t.

To fix the bug in getQualifier and getComplex, I decided to use recursion. I created a findAndSpan function which uses .indexOf() to find the first instance of the word or phrase. It splits the sentence into 3 parts: before the phrase, the phrase, after the phrase. The recursion works by passing the ‘after the phrase’ string back into the function. This will continue until there are no more instances of the phrase, where the string will just be passed back.

function findAndSpan(sentence, string, type) { let index = sentence.toLowerCase().indexOf(key); if (index >= 0) { sentence = sentence.slice(0, index) + `` + sentence.slice(index, index + key.length) + "" + findAndSpan( sentence.slice(index + key.length), key, type); } return sentence; }

Something very similar had to be done for the passive voice. The recursion was in an almost identical pattern, passing the leftover array items instead of the leftover string. The result of the recursion call was spread into an array that was then returned. Now the app can deal with repeated adverbs, qualifiers, complex phrases and passive voice uses.

Statistics Counter

The last thing that I wanted to get working was the nice line of boxes informing you on how many adverbs or complex words you’d used.

To store the data I created an object with keys for each of the parameters I wanted to count. I started by having this variable as a global variable but knew I would have to change that later.

Now I had to populate the values. This was done by incrementing the value every time it was found.

data.sentences += sentence.length or data.adverbs += 1

The values needed to be reset every time the scan was run to make sure that values didn’t continuously increase.

With the values I needed, I had to get them rendering on the screen. I altered the structure of the html file so that the input box and output area were in a div on the left, leaving a right div for the counters. These counters are empty divs with an appropriate id and class as well as a ‘counter’ class.

With these divs, I used document.querySelector to set the inner html for each of the counters using the data that had been collected. With a little bit of styling of the ‘counter’ class, the web app was complete. Try it out here or look at my code here.