diff --git a/src/buildPronoun.js b/src/buildPronoun.js index 9127a3358..acfaa89e9 100644 --- a/src/buildPronoun.js +++ b/src/buildPronoun.js @@ -59,7 +59,7 @@ const buildPronounFromTemplate = (key, template) => { }; const buildPronounFromSlashes = (config, path) => { - const chunks = path.split(/(? !!s); + const chunks = path.split(/(? { } } } else { - morphemeChunks.push(unescapeControlSymbols(chunk)); + if (chunk === '~') { + morphemeChunks.push(null); + } else if (chunk === ' ') { + morphemeChunks.push(''); + } else { + morphemeChunks.push(unescapeControlSymbols(chunk)); + } } } if (description.length > Pronoun.DESCRIPTION_MAXLENGTH) { diff --git a/src/classes.js b/src/classes.js index 10ebfc341..baaf6fb63 100644 --- a/src/classes.js +++ b/src/classes.js @@ -478,7 +478,16 @@ export class Pronoun { } else { chunks = Object.values(this.morphemes); } - chunks = chunks.map(escapeControlSymbols); + chunks = chunks.map((chunk) => { + if (chunk === null) { + return '~'; + } else if (chunk === '') { + // use an extra space because double slashes get replaced by a single one during a request + return ' '; + } else { + return escapeControlSymbols(chunk); + } + }); if (this.plural[0]) { chunks.push(':plural'); @@ -490,7 +499,8 @@ export class Pronoun { chunks.push(`:description=${escapeControlSymbols(this.description)}`); } - return chunks.join('/'); + // encode a trailing space so that it does not get removed during a request + return chunks.join('/').replace(/ $/, encodeURI(' ')); } static from(data) { diff --git a/src/helpers.js b/src/helpers.js index dfbfaafdb..6694587b2 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -269,12 +269,18 @@ const escapeChars = { export const escapeHtml = (text) => text.replace(/[&<>"]/g, (tag) => escapeChars[tag] || tag); export const escapeControlSymbols = (text) => { + if (text === null) { + return null; + } // a backtick is used because browsers replace backslashes // with forward slashes if they are not url-encoded return text.replaceAll(/[`&/|,]/g, '`$&'); }; export const unescapeControlSymbols = (text) => { + if (text === null) { + return null; + } return text.replaceAll(/`(.)/g, '$1'); }; diff --git a/test/buildPronoun.test.js b/test/buildPronoun.test.js index 38a17d842..20291328b 100644 --- a/test/buildPronoun.test.js +++ b/test/buildPronoun.test.js @@ -162,6 +162,21 @@ describe('when configured that slashes contain all morphemes', () => { actual.toBeDefined(); actual.toEqual(generatedPronouns.aerPluralWithDescription); }); + test('builds generated pronoun with some morphemes empty', () => { + const actual = expect(buildPronoun(pronouns, 'ae/aer/aer/ /aerself')); + actual.toBeDefined(); + actual.toEqual(generatedPronouns.aerWithEmptyPossessivePronoun); + }); + test('builds generated pronoun with morpheme at end empty', () => { + const actual = expect(buildPronoun(pronouns, 'ae/aer/aer/aers/')); + actual.toBeDefined(); + actual.toEqual(generatedPronouns.aerWithEmptyReflexive); + }); + test('builds generated pronoun with some morphemes unset', () => { + const actual = expect(buildPronoun(pronouns, 'ae/aer/aer/~/aerself')); + actual.toBeDefined(); + actual.toEqual(generatedPronouns.aerWithUnsetPossessivePronoun); + }); test('builds nothing if morphemes are missing', () => { expect(buildPronoun(pronouns, 'ae/aer/aer/aerself')).toBeUndefined(); }); diff --git a/test/classes.test.js b/test/classes.test.js index 936a55e70..2f876f071 100644 --- a/test/classes.test.js +++ b/test/classes.test.js @@ -56,6 +56,18 @@ describe('formatting a pronoun with slashes', () => { expect(generatedPronouns.sSlashHe.toStringSlashes()) .toEqual('s`/he/hir/hir/hirs/hirself'); }); + test('empty morphemes receive space as placeholder', () => { + expect(generatedPronouns.aerWithEmptyPossessivePronoun.toStringSlashes()) + .toEqual('ae/aer/aer/ /aerself'); + }); + test('empty morphemes at end receive url-encoded space as placeholder', () => { + expect(generatedPronouns.aerWithEmptyReflexive.toStringSlashes()) + .toEqual('ae/aer/aer/aers/%20'); + }); + test('unset morphemes receive tilde as placeholder', () => { + expect(generatedPronouns.aerWithUnsetPossessivePronoun.toStringSlashes()) + .toEqual('ae/aer/aer/~/aerself'); + }); test('adds plural modifier if necessary', () => { expect(generatedPronouns.aerPlural.toStringSlashes()) .toEqual('ae/aer/aer/aers/aerselves/:plural'); diff --git a/test/fixtures/pronouns.js b/test/fixtures/pronouns.js index 284b688fe..9009f0ca5 100644 --- a/test/fixtures/pronouns.js +++ b/test/fixtures/pronouns.js @@ -164,6 +164,23 @@ const aerWithEmptyPossessivePronoun = new Pronoun( '__generator__', false, ); +const aerWithEmptyReflexive = new Pronoun( + 'ae/aer', + '', + false, + { + pronoun_subject: 'ae', + pronoun_object: 'aer', + possessive_determiner: 'aer', + possessive_pronoun: 'aers', + reflexive: '', + }, + [false], + [false], + [], + '__generator__', + false, +); const aerWithUnsetPossessivePronoun = new Pronoun( 'ae/aer', '', @@ -207,6 +224,7 @@ export const generated = { aerWithDescription, aerPluralWithDescription, aerWithEmptyPossessivePronoun, + aerWithEmptyReflexive, aerWithUnsetPossessivePronoun, sSlashHe, }; diff --git a/test/helpers.test.js b/test/helpers.test.js index 232d48c1f..7d00ed895 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -36,12 +36,18 @@ const controlSymbols = [ ]; describe('when escaping control symbols', () => { + test('safely handles null', () => { + expect(escapeControlSymbols(null)).toBeNull(); + }); test.each(controlSymbols)('$description get escaped with `', ({ unescaped, escaped }) => { expect(escapeControlSymbols(unescaped)).toBe(escaped); }); }); describe('when unescaping control symbols', () => { + test('safely handles null', () => { + expect(unescapeControlSymbols(null)).toBeNull(); + }); test.each(controlSymbols)('$description get unescaped', ({ unescaped, escaped }) => { expect(unescapeControlSymbols(escaped)).toBe(unescaped); });