Skip to content

Commit

Permalink
Add 'suggestionMode'
Browse files Browse the repository at this point in the history
- Do not replace the terms in DOM
- Mark the matches and provide the list of available replacements
  in a data- attribute

Fixes #2
  • Loading branch information
mooeypoo committed May 4, 2021
1 parent bf9db25 commit 06f1372
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 10 deletions.
43 changes: 34 additions & 9 deletions src/DomManager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import domino from 'domino';
import serialize from 'w3c-xmlserializer';
import Replacer from './Replacer';
import Utils from './Utils';

/**
* Manage the DOM documnent
*
Expand All @@ -26,6 +28,11 @@ class DomManager {
* same case from the original word when replacing. The only cases
* that are kept are capitalization ('Foo') and full caps ('FOO')
* otherwise the match will be outputted all-lowercase.
* @param {boolean} [config.suggestionMode] A mode that does not
* replace the given words. Instead, it tags the matches with a
* span that provides the possible option replacements and whether
* they are ambiguous. This can then be used in a frontend to provide
* suggestions for replacements without outright replacing the words.
* @param {string} [config.css] A css string to inject to the page.
* This is used mostly to style the replacement term classes
* on the outputted html.
Expand All @@ -41,6 +48,7 @@ class DomManager {
this.showOriginalTerm = config.showOriginalTerm === undefined ? true : config.showOriginalTerm;
this.stripScriptTags = config.stripScriptTags === undefined ? true : config.stripScriptTags;
this.keepSameCase = config.keepSameCase === undefined ? true : config.keepSameCase;
this.suggestionMode = !!config.suggestionMode;
this.showDictionaryKeys = !!config.showDictionaryKeys;
this.termClass = config.termClass || 'replaced-term';
this.ambiguousClass = config.ambiguousClass || 'ambiguous-term';
Expand Down Expand Up @@ -188,30 +196,47 @@ class DomManager {

// For all matches, perform the replacement
const newNodeContent = node.textContent.replace(regex, match => {
// Look it up in the dictionary
const replacementData = this.replacer
.getSingleReplacementData(match, dictKeyFrom, dictKeyTo);

// Wrap with span and class (add ambiguous class if needed)
const props = [];
const cssClasses = [this.termClass];
if (this.showOriginalTerm) {
if (this.showOriginalTerm && !this.suggestionMode) {
props.push(`title="${match}"`);
}

if (this.showDictionaryKeys) {
// Add data- props
props.push(`data-replaced-from="${dictKeyFrom}"`);
props.push(`data-replaced-to="${dictKeyTo}"`);
}

// Look it up in the dictionary
let replacedTerm = match;
let replacementData = {};
if (!this.suggestionMode) {
replacementData = this.replacer
.getSingleReplacementData(match, dictKeyFrom, dictKeyTo);

replacedTerm = this.keepSameCase ?
this.replacer.matchCase(match, replacementData.term) :
replacementData.term;
} else {
// Suggestion mode
replacementData = this.replacer
.getAllReplacementsData(match, dictKeyFrom, dictKeyTo);

const termList = replacementData.terms
.map(t => {
return `'${Utils.encodeHTML(t)}'`;
})
.join(',');
props.push(`data-replacement-options="[${termList}]"`);
}

if (replacementData.ambiguous) {
cssClasses.push(this.ambiguousClass);
}

const replacedTerm = this.keepSameCase ?
this.replacer.matchCase(match, replacementData.term) :
replacementData.term;

// Replacement
return `<span class="${cssClasses.join(' ')}" ${props.join(' ')}>${replacedTerm}</span>`;
});

Expand Down
24 changes: 24 additions & 0 deletions src/Replacer.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ class Replacer {
.getSingleOption(keyFrom, match, keyTo);
}

/**
* Get an object representing the data for all available
* replacements for the given match.
*
* @param {string} match Match to be replaced
* @param {string} keyFrom Dictionary key for the match
* @param {string} keyTo Dictionary key for the replacement
* @return {Object} Object representing the data for the replacement.
* The object contains the term and whether it is ambiguous:
* {
* terms: {string[]}
* ambiguous: {boolean}
* }
*/
getAllReplacementsData(match, keyFrom, keyTo) {
const data = this.dictionary.getOptions(keyFrom, match) || {};
const terms = data && data[keyTo];

return {
ambiguous: !!data.ambiguous,
terms: Array.isArray(terms) ? terms : [terms]
};
}

/**
* Output the replacement text with the same approximate
* character case of the original match.
Expand Down
15 changes: 15 additions & 0 deletions src/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ class Utils {
static capitalizeString(str) {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}

/**
* Safely encode string for use inside HTML properties
*
* @param {string} str Given string
* @return {string} Safe string for DOM properties
*/
static encodeHTML(str) {
return str
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}

export default Utils;
36 changes: 35 additions & 1 deletion test/DomManager.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,41 @@ describe('DomManager test', () => {
});
});

describe('sanitize', () => {
describe('HTML suggestion mode', () => {
const manager = new DomManager(dictDefinition, { suggestionMode: true });
const testCases = [
{
msg: 'Suggested replacement with one option',
input: '<h1>Term1!</h1>',
expected: '<h1><span class="replaced-term" data-replacement-options="[\'flippedterm1\']">Term1</span>!</h1>'
},
{
msg: 'Suggested replacement with multiple options',
input: '<h1>Term2!</h1>',
expected: '<h1><span class="replaced-term" data-replacement-options="[\'flippedterm2opt1\',\'flippedterm2opt2\']">Term2</span>!</h1>'
},
{
msg: 'Suggested replacement with multiple options, ambiguous',
input: '<h1>Term4amb!</h1>',
expected: '<h1><span class="replaced-term ambiguous-term" data-replacement-options="[\'flippedterm4ambopt1\',\'flippedterm4ambopt2\']">Term4amb</span>!</h1>'
}
];

testCases.forEach(t => {
const result = manager.replace(t.input, 'dict1','dict2', { replaceBothWays: !!t.both });
it(t.msg, () => {
if (Array.isArray(t.expected)) {
// In this case, we may have two options, so check if at least one (but no others) is correct
const equalsToAtLeastOne = result === wrapHtmlResult(t.expected[0]) || result === wrapHtmlResult(t.expected[1]);
expect(equalsToAtLeastOne).to.be.true;
} else {
expect(result).to.be.equal(wrapHtmlResult(t.expected));
}
});
});
});

describe('sanitize', () => {
const dict = new Dictionary('test dictionary', []);
const manager = new DomManager(dictDefinition);

Expand Down

0 comments on commit 06f1372

Please sign in to comment.