yoastseo
Version:
Yoast client-side content analysis
565 lines (520 loc) • 22.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _lodash = require("lodash");
var _core = _interopRequireDefault(require("tokenizer2/core"));
var _quotes = require("../sanitize/quotes.js");
var _abbreviations = _interopRequireDefault(require("../../languages/en/config/abbreviations"));
var _createRegexFromArray = _interopRequireDefault(require("../regex/createRegexFromArray"));
var _wordBoundaries = _interopRequireDefault(require("../../../config/wordBoundaries"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
// All characters that indicate a sentence delimiter.
const fullStop = ".";
const fullStopRegex = new RegExp("^[" + fullStop + "]$");
const smallerThanContentRegex = /^<[^><]*$/;
const htmlStartRegex = /^<([^>\s/]+)[^>]*>$/mi;
const htmlEndRegex = /^<\/([^>\s]+)[^>]*>$/mi;
const blockStartRegex = /^\s*[[({]\s*$/;
const blockEndRegex = /^\s*[\])}]\s*$/;
const abbreviationsPreparedForRegex = _abbreviations.default.map(abbreviation => abbreviation.replace(".", "\\."));
const abbreviationsRegex = (0, _createRegexFromArray.default)(abbreviationsPreparedForRegex);
const wordBoundariesForRegex = "(^|$|[" + (0, _wordBoundaries.default)().map(boundary => "\\" + boundary).join("") + "])";
const lastCharacterPartOfInitialsRegex = new RegExp(wordBoundariesForRegex + "[A-Za-z]$");
// Constants to be used in isValidTagPair.
// A regex to get the tag type.
const tagTypeRegex = /<\/?([^\s]+?)(\s|>)/;
// Semantic tags (as opposed to style tags) are tags that are used to structure the text.
const semanticTags = ["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "span", "li", "main"];
/**
* Class for tokenizing a (html) text into sentences.
*/
class SentenceTokenizer {
/**
* Constructor
* @constructor
*/
constructor() {
/*
* \u2026 - ellipsis.
* \u06D4 - Urdu full stop.
* \u061f - Arabic question mark.
*/
this.sentenceDelimiters = "”〞〟„』›»’‛`\"?!\u2026\u06d4\u061f";
}
/**
* Gets the sentence delimiters.
*
* @returns {string} The sentence delimiters.
*/
getSentenceDelimiters() {
return this.sentenceDelimiters;
}
/**
* Returns whether or not a certain character is a number.
*
* @param {string} character The character to check.
* @returns {boolean} Whether or not the character is a capital letter.
*/
isNumber(character) {
return !(0, _lodash.isNaN)(parseInt(character, 10));
}
/**
* Returns whether or not a given HTML tag is a break tag.
*
* @param {string} htmlTag The HTML tag to check.
* @returns {boolean} Whether or not the given HTML tag is a break tag.
*/
isBreakTag(htmlTag) {
return /<\/?br/.test(htmlTag);
}
/**
* Returns whether or not a given character is quotation mark.
*
* @param {string} character The character to check.
* @returns {boolean} Whether or not the given character is a quotation mark.
*/
isQuotation(character) {
character = (0, _quotes.normalize)(character);
return "'" === character || "\"" === character;
}
/**
* A mock definition of this function. This function is only used in extensions for languages that use an ordinal dot.
*
* @returns {boolean} Always returns false as it is a language specific implementation if a language has an ordinal dot.
*/
endsWithOrdinalDot() {
return false;
}
/**
* Returns whether or not a given character is a punctuation mark that can be at the beginning
* of a sentence, like ¿ and ¡ used in Spanish.
*
* @param {string} character The character to check.
* @returns {boolean} Whether or not the given character is a punctuation mark.
*/
isPunctuation(character) {
return "¿" === character || "¡" === character;
}
/**
* Removes duplicate whitespace from a given text.
*
* @param {string} text The text with duplicate whitespace.
* @returns {string} The text without duplicate whitespace.
*/
removeDuplicateWhitespace(text) {
return text.replace(/\s+/, " ");
}
/**
* Returns whether or not a certain character is a capital letter.
*
* @param {string} character The character to check.
* @returns {boolean} Whether or not the character is a capital letter.
*/
isCapitalLetter(character) {
return character !== character.toLocaleLowerCase();
}
/**
* Checks whether the given character is a smaller than sign.
*
* This function is used to make sure that tokenizing the content after
* the smaller than sign works as expected.
* E.g. 'A sentence. < Hello world!' = ['A sentence.', '< Hello world!'].
*
* @param {string} character The character to check.
* @returns {boolean} Whether the character is a smaller than sign ('<') or not.
*/
isSmallerThanSign(character) {
return character === "<";
}
/**
* Retrieves the next two characters from an array with the two next tokens.
*
* @param {Array} nextTokens The two next tokens. Might be undefined.
* @returns {string} The next two characters.
*/
getNextTwoCharacters(nextTokens) {
let next = "";
if (!(0, _lodash.isUndefined)(nextTokens[0])) {
next += nextTokens[0].src;
}
if (!(0, _lodash.isUndefined)(nextTokens[1])) {
next += nextTokens[1].src;
}
next = this.removeDuplicateWhitespace(next);
return next;
}
/**
* Checks whether a character is from a language that's written from right to left.
* These languages don't have capital letter forms. Therefore any letter from these languages is a
* potential sentence beginning.
*
* @param {string} letter The letter to check.
*
* @returns {boolean} Whether the letter is from an LTR language.
*/
isLetterFromSpecificLanguage(letter) {
const ltrLetterRanges = [
// Hebrew characters.
/^[\u0590-\u05fe]+$/i,
// Arabic characters (used for Arabic, Farsi, Urdu).
/^[\u0600-\u06FF]+$/i,
// Additional Farsi characters.
/^[\uFB8A\u067E\u0686\u06AF]+$/i];
return ltrLetterRanges.some(ltrLetterRange => ltrLetterRange.test(letter));
}
/**
* Checks if the sentenceBeginning beginning is a valid beginning.
*
* @param {string} sentenceBeginning The beginning of the sentence to validate.
* @returns {boolean} Returns true if it is a valid beginning, false if it is not.
*/
isValidSentenceBeginning(sentenceBeginning) {
return this.isCapitalLetter(sentenceBeginning) || this.isLetterFromSpecificLanguage(sentenceBeginning) || this.isNumber(sentenceBeginning) || this.isQuotation(sentenceBeginning) || this.isPunctuation(sentenceBeginning) || this.isSmallerThanSign(sentenceBeginning);
}
/**
* Checks if the token is a valid sentence start.
*
* @param {Object} token The token to validate.
* @returns {boolean} Returns true if the token is valid sentence start, false if it is not.
*/
isSentenceStart(token) {
return !(0, _lodash.isUndefined)(token) && ("html-start" === token.type || "html-end" === token.type || "block-start" === token.type);
}
/**
* Checks if the token is a valid sentence ending. A valid sentence ending is either a full stop or another
* delimiter such as "?", "!", etc.
*
* @param {Object} token The token to validate.
* @returns {boolean} Returns true if the token is valid sentence ending, false if it is not.
*/
isSentenceEnding(token) {
return !(0, _lodash.isUndefined)(token) && (token.type === "full-stop" || token.type === "sentence-delimiter");
}
/**
* Checks if a full stop is part of a person's initials.
*
* Tests if tokens exist. Then tests if the tokens are of the right type.
* For previous token, it checks if the sentence ends with a single letter.
* For nextToken it checks if it is a single letter.
* Checks if next token is followed by a full stop.
*
* @param {object} token The current token (must be a full stop).
* @param {object} previousToken The token before the full stop.
* @param {object} nextToken The token following the full stop.
* @param {object} secondToNextToken The second token after the full stop.
* @returns {boolean} True if a full stop is part of a person's initials, False if the full stop is not part of a person's initials.
*/
isPartOfPersonInitial(token, previousToken, nextToken, secondToNextToken) {
return !(0, _lodash.isUndefined)(token) && !(0, _lodash.isUndefined)(nextToken) && !(0, _lodash.isUndefined)(secondToNextToken) && !(0, _lodash.isUndefined)(previousToken) && token.type === "full-stop" && previousToken.type === "sentence" && lastCharacterPartOfInitialsRegex.test(previousToken.src) && nextToken.type === "sentence" && nextToken.src.trim().length === 1 && secondToNextToken.type === "full-stop";
}
/**
* Tokens that represent a '<', followed by content until it enters another '<' or '>'
* gets another pass by the tokenizer.
*
* @param {Object} token A token of type 'smaller-than-sign-content'.
* @param {string[]} tokenSentences The current array of found sentences. Sentences may get added by this method.
* @param {string} currentSentence The current sentence. Sentence parts may get appended by this method.
* @returns {{tokenSentences, currentSentence}} The found sentences and the current sentence, appended when necessary.
*/
tokenizeSmallerThanContent(token, tokenSentences, currentSentence) {
/*
Remove the '<' from the text, to avoid matching this rule
recursively again and again.
We add it again later on.
*/
const localText = token.src.substring(1);
// Tokenize the current smaller-than-content token without the first '<'.
const tokenizerResult = this.createTokenizer();
this.tokenize(tokenizerResult.tokenizer, localText);
const localSentences = this.getSentencesFromTokens(tokenizerResult.tokens, false);
localSentences[0] = (0, _lodash.isUndefined)(localSentences[0]) ? "<" : "<" + localSentences[0];
/*
* When the first sentence has a valid sentence beginning.
* Add the currently build sentence to the sentences.
* Start building the next sentence.
*/
if (this.isValidSentenceBeginning(localSentences[0])) {
tokenSentences.push(currentSentence);
currentSentence = "";
}
currentSentence += localSentences[0];
if (localSentences.length > 1) {
/*
There is a new sentence after the first,
add and reset the current sentence.
*/
tokenSentences.push(currentSentence);
currentSentence = "";
// Remove the first sentence (we do not need to add it again).
localSentences.shift();
// Last sentence gets special treatment.
const lastSentence = localSentences.pop();
// Add the remaining found sentences.
localSentences.forEach(sentence => {
tokenSentences.push(sentence);
});
const sentenceEndRegex = new RegExp("[" + fullStop + this.getSentenceDelimiters() + "]$");
// Check if the last sentence has a valid sentence ending.
if (lastSentence.match(sentenceEndRegex)) {
// If so, add it as a sentence.
tokenSentences.push(lastSentence);
} else {
// If not, start making a new one.
currentSentence = lastSentence;
}
}
return {
tokenSentences,
currentSentence
};
}
/**
* Creates a tokenizer.
*
* @returns {Object} The tokenizer and the tokens.
*/
createTokenizer() {
const sentenceDelimiterRegex = new RegExp("^[" + this.getSentenceDelimiters() + "]$");
const sentenceRegex = new RegExp("^[^" + fullStop + this.getSentenceDelimiters() + "<\\(\\)\\[\\]]+$");
const tokens = [];
const tokenizer = (0, _core.default)(function (token) {
tokens.push(token);
});
tokenizer.addRule(fullStopRegex, "full-stop");
tokenizer.addRule(smallerThanContentRegex, "smaller-than-sign-content");
tokenizer.addRule(htmlStartRegex, "html-start");
tokenizer.addRule(htmlEndRegex, "html-end");
tokenizer.addRule(blockStartRegex, "block-start");
tokenizer.addRule(blockEndRegex, "block-end");
tokenizer.addRule(sentenceDelimiterRegex, "sentence-delimiter");
tokenizer.addRule(sentenceRegex, "sentence");
return {
tokenizer,
tokens
};
}
/**
* Tokenizes the given text using the given tokenizer.
*
* @param {Object} tokenizer The tokenizer to use.
* @param {string} text The text to tokenize.
* @returns {void}
*/
tokenize(tokenizer, text) {
tokenizer.onText(text);
try {
tokenizer.end();
} catch (e) {
console.error("Tokenizer end error:", e, e.tokenizer2);
}
}
/**
* Checks if a string ends with an abbreviation.
* @param {string} currentSentence A (part of) a sentence.
* @returns {boolean} True if the string ends with an abbreviation that is in abbreviations.js. Otherwise, False.
*/
endsWithAbbreviation(currentSentence) {
const matchedAbbreviations = currentSentence.match(abbreviationsRegex);
if (!matchedAbbreviations) {
return false;
}
const lastAbbreviation = matchedAbbreviations.pop();
return currentSentence.endsWith(lastAbbreviation);
}
/**
* Checks whether the given tokens are a valid html tag pair.
* Note that this method is not a full html tag validator. It should be replaced with a better solution once the html parser is implemented.
*
* @param {object} firstToken The first token to check. It is asserted that this token contains/is an opening html tag.
* @param {object} lastToken The last token to check. It is asserted that this token contains/is a closing html tag.
*
* @returns {boolean} True if the tokens are a valid html tag pair. Otherwise, False.
*/
isValidTagPair(firstToken, lastToken) {
const firstTokenText = firstToken.src;
const lastTokenText = lastToken.src;
// Get the tag types.
const firstTagType = firstTokenText.match(tagTypeRegex)[1];
const lastTagType = lastTokenText.match(tagTypeRegex)[1];
// Check if the tags are the same and if they are a semantic tag (p, div, h1, h2, h3, h4, h5, h6, span).
return firstTagType === lastTagType && semanticTags.includes(firstTagType);
}
/**
* Returns an array of sentences for a given array of tokens, assumes that the text has already been split into blocks.
*
* @param {Object[]} tokenArray The tokens from the sentence tokenizer.
* @param {boolean} [trimSentences=true] Whether to trim the sentences at the end or not.
*
* @returns {string[]} A list of sentences.
*/
getSentencesFromTokens(tokenArray, trimSentences = true) {
let tokenSentences = [],
currentSentence = "",
nextSentenceStart,
sliced;
// Drop the first and last HTML tag if both are present.
do {
sliced = false;
const firstToken = tokenArray[0];
const lastToken = tokenArray[tokenArray.length - 1];
if (firstToken && lastToken && firstToken.type === "html-start" && lastToken.type === "html-end" && this.isValidTagPair(firstToken, lastToken)) {
tokenArray = tokenArray.slice(1, tokenArray.length - 1);
sliced = true;
}
} while (sliced && tokenArray.length > 1);
tokenArray.forEach((token, i) => {
let hasNextSentence, nextCharacters, tokenizeResults;
const nextToken = tokenArray[i + 1];
const previousToken = tokenArray[i - 1];
const secondToNextToken = tokenArray[i + 2];
nextCharacters = this.getNextTwoCharacters([nextToken, secondToNextToken]);
// For a new sentence we need to check the next two characters.
hasNextSentence = nextCharacters.length >= 2;
nextSentenceStart = hasNextSentence ? nextCharacters[1] : "";
switch (token.type) {
case "html-start":
case "html-end":
if (this.isBreakTag(token.src)) {
tokenSentences.push(currentSentence);
currentSentence = "";
} else {
currentSentence += token.src;
}
break;
case "smaller-than-sign-content":
tokenizeResults = this.tokenizeSmallerThanContent(token, tokenSentences, currentSentence);
tokenSentences = tokenizeResults.tokenSentences;
currentSentence = tokenizeResults.currentSentence;
break;
case "sentence":
currentSentence += token.src;
break;
case "sentence-delimiter":
currentSentence += token.src;
/*
* Only split text into sentences if:
* the next token is defined, AND
* the next token type is neither "block-end" nor "sentence-delimiter", AND
* the next token first character is a white space
*/
if (!(0, _lodash.isUndefined)(nextToken) && "block-end" !== nextToken.type && "sentence-delimiter" !== nextToken.type && this.isCharacterASpace(nextToken.src[0])) {
// Don't split on quotation marks unless they're preceded by a full stop.
if (this.isQuotation(token.src) && previousToken && previousToken.src !== ".") {
break;
}
/*
* Only split on ellipsis or quotation marks when:
* a) There is a next sentence, and the next character is a valid sentence beginning preceded by a white space, OR
* b) The next token is a sentence start
*/
if (this.isQuotation(token.src) || token.src === "…") {
currentSentence = this.getValidSentence(hasNextSentence, nextSentenceStart, nextCharacters, nextToken, tokenSentences, currentSentence);
} else {
tokenSentences.push(currentSentence);
currentSentence = "";
}
}
break;
case "full-stop":
currentSentence += token.src;
nextCharacters = this.getNextTwoCharacters([nextToken, secondToNextToken]);
// For a new sentence we need to check the next two characters.
hasNextSentence = nextCharacters.length >= 2;
nextSentenceStart = hasNextSentence ? nextCharacters[1] : "";
// If the current sentence ends with an abbreviation, the full stop does not split the sentence.
if (this.endsWithAbbreviation(currentSentence)) {
break;
}
// It should not split the text if the first character of the potential next sentence is a number.
if (hasNextSentence && this.isNumber(nextCharacters[0])) {
break;
}
// If the full stop is part of a person's initials, don't split sentence.
if (this.isPartOfPersonInitial(token, previousToken, nextToken, secondToNextToken)) {
break;
}
// If the full stop is an ordinal dot (in German), then don't break the sentence.
// This check should be done after hasNextSentence && this.isNumber( nextCharacters[ 0 ] ) (above).
// Because otherwise it could break before that test.
if (this.endsWithOrdinalDot(currentSentence)) {
break;
}
/*
* Only split on full stop when:
* a) There is a next sentence, and the next character is a valid sentence beginning preceded by a white space, OR
* b) The next token is a sentence start
*/
currentSentence = this.getValidSentence(hasNextSentence, nextSentenceStart, nextCharacters, nextToken, tokenSentences, currentSentence);
break;
case "block-start":
currentSentence += token.src;
break;
case "block-end":
currentSentence += token.src;
nextCharacters = this.getNextTwoCharacters([nextToken, secondToNextToken]);
// For a new sentence we need to check the next two characters.
hasNextSentence = nextCharacters.length >= 2;
nextSentenceStart = hasNextSentence ? nextCharacters[0] : "";
/* Don't split if:
* - The next character is a number. For example: IPv4-numbers.
* - The block end is preceded by a valid sentence ending, but not followed by a valid sentence beginning.
*/
if (hasNextSentence && this.isNumber(nextCharacters[0]) || this.isSentenceEnding(previousToken) && !(this.isValidSentenceBeginning(nextSentenceStart) || this.isSentenceStart(nextToken))) {
break;
}
/*
* Split if:
* - The block end is preceded by a sentence ending and followed by a valid sentence beginning.
*/
if (this.isSentenceEnding(previousToken) && (this.isSentenceStart(nextToken) || this.isValidSentenceBeginning(nextSentenceStart))) {
tokenSentences.push(currentSentence);
currentSentence = "";
}
break;
}
});
if ("" !== currentSentence) {
tokenSentences.push(currentSentence);
}
if (trimSentences) {
tokenSentences = (0, _lodash.map)(tokenSentences, function (sentence) {
return sentence.trim();
});
}
return tokenSentences;
}
/**
* Gets the current sentence when:
* a) There is a next sentence, and the next character is a valid sentence beginning preceded by a white space, OR
* b) The next token is a sentence start
*
* @param {boolean} hasNextSentence Whether the next characters are more than two.
* @param {string} nextSentenceStart The second character of the next characters.
* @param {string} nextCharacters The string values of the next two tokens.
* @param {object} nextToken The next token object.
* @param {array} tokenSentences The array of pushed valid sentences.
* @param {string} currentSentence The current sentence.
*
* @returns {string} The current sentence.
*/
getValidSentence(hasNextSentence, nextSentenceStart, nextCharacters, nextToken, tokenSentences, currentSentence) {
if (hasNextSentence && this.isValidSentenceBeginning(nextSentenceStart) && this.isCharacterASpace(nextCharacters[0]) || this.isSentenceStart(nextToken)) {
tokenSentences.push(currentSentence);
currentSentence = "";
}
return currentSentence;
}
/**
* Checks if the character is a whitespace.
*
* @param {string} character The character to check.
* @returns {boolean} Whether the character is a whitespace.
*/
isCharacterASpace(character) {
return /\s/.test(character);
}
}
exports.default = SentenceTokenizer;
//# sourceMappingURL=SentenceTokenizer.js.map