Skip to content

Commit b0ab52a

Browse files
authored
Merge pull request #820 from software-mansion-labs/@Skalakid/fix-blockquote-formatting
Unify the blockquote parsing logic for messages and rawInput
2 parents 9e8e800 + 6611dab commit b0ab52a

File tree

2 files changed

+114
-151
lines changed

2 files changed

+114
-151
lines changed

__tests__/ExpensiMark-HTML-test.js

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,13 +1125,13 @@ test('Test quotes markdown replacement and removing <br/> from <br/><pre> and </
11251125

11261126
test('Test quotes markdown replacement skipping blank quotes', () => {
11271127
const testString = '> \n>';
1128-
const resultString = '&gt; <br />&gt;';
1128+
const resultString = '<blockquote> </blockquote>&gt;';
11291129
expect(parser.replace(testString)).toBe(resultString);
11301130
});
11311131

11321132
test('Test quotes markdown replacement with text starts with blank quote', () => {
11331133
const testString = '> \ntest';
1134-
const resultString = '&gt; <br />test';
1134+
const resultString = '<blockquote> </blockquote>test';
11351135
expect(parser.replace(testString)).toBe(resultString);
11361136
});
11371137

@@ -1143,7 +1143,7 @@ test('Test quotes markdown replacement with quotes starts with blank quote row',
11431143

11441144
test('Test quotes markdown replacement with quotes ends with blank quote rows', () => {
11451145
const testString = '> test\n> \n>';
1146-
const resultString = '<blockquote>test<br /> <br /> </blockquote>';
1146+
const resultString = '<blockquote>test<br /> </blockquote>&gt;';
11471147
expect(parser.replace(testString)).toBe(resultString);
11481148
});
11491149

@@ -1162,14 +1162,14 @@ test('Test quotes markdown replacement with quotes includes multiple middle blan
11621162
test('Test quotes markdown replacement with text includes blank quotes', () => {
11631163
const testString = '> \n> quote1 line a\n> quote1 line b\ntest\n> \ntest\n> quote2 line a\n> \n> \n> quote2 line b with an empty line above';
11641164
const resultString =
1165-
'<blockquote> <br />quote1 line a<br />quote1 line b</blockquote>test<br />&gt; <br />test<br /><blockquote>quote2 line a<br /> <br /> <br />quote2 line b with an empty line above</blockquote>';
1165+
'<blockquote> <br />quote1 line a<br />quote1 line b</blockquote>test<br /><blockquote> </blockquote>test<br /><blockquote>quote2 line a<br /> <br /> <br />quote2 line b with an empty line above</blockquote>';
11661166
expect(parser.replace(testString)).toBe(resultString);
11671167
});
11681168

11691169
test('Test quotes markdown replacement with text includes multiple spaces', () => {
1170-
const quoteTestStartString = '> Indented\n>No indent\n> Indented \n> > Nested indented \n> Indented ';
1170+
const quoteTestStartString = '> Indented\n>No indent\n> Indented \n>> Nested indented \n> > Nested not indented \n> Indented ';
11711171
const quoteTestReplacedString =
1172-
'<blockquote> Indented</blockquote>&gt;No indent<br /><blockquote> Indented <br /><blockquote> Nested indented </blockquote> Indented </blockquote>';
1172+
'<blockquote> Indented</blockquote>&gt;No indent<br /><blockquote> Indented <br /><blockquote> Nested indented </blockquote>&gt; Nested not indented <br /> Indented </blockquote>';
11731173
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
11741174
});
11751175

@@ -1192,13 +1192,7 @@ test('Test markdown quotes without spaces after > should not be parsed', () => {
11921192

11931193
test('Test markdown quotes without spaces after > should not be parsed', () => {
11941194
const testString = '> > > test';
1195-
const resultString = '<blockquote><blockquote><blockquote>test</blockquote></blockquote></blockquote>';
1196-
expect(parser.replace(testString)).toBe(resultString);
1197-
});
1198-
1199-
test('Test markdown quotes without spaces after > should not be parsed', () => {
1200-
const testString = '>>> test';
1201-
const resultString = '&gt;&gt;&gt; test';
1195+
const resultString = '<blockquote>&gt; &gt; test</blockquote>';
12021196
expect(parser.replace(testString)).toBe(resultString);
12031197
});
12041198

@@ -2027,54 +2021,54 @@ test('Test italic/bold/strikethrough markdown to keep consistency', () => {
20272021

20282022
describe('multi-level blockquote', () => {
20292023
test('test max level of blockquote (3)', () => {
2030-
const quoteTestStartString = '> > > > > Hello world';
2031-
const quoteTestReplacedString = '<blockquote><blockquote><blockquote>&gt; &gt; Hello world</blockquote></blockquote></blockquote>';
2024+
const quoteTestStartString = '>>>>> Hello world';
2025+
const quoteTestReplacedString = '<blockquote><blockquote><blockquote>&gt;&gt; Hello world</blockquote></blockquote></blockquote>';
20322026

20332027
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20342028
});
20352029
test('multi-level blockquote with single space', () => {
2036-
const quoteTestStartString = '> > > Hello world';
2030+
const quoteTestStartString = '>>> Hello world';
20372031
const quoteTestReplacedString = '<blockquote><blockquote><blockquote>Hello world</blockquote></blockquote></blockquote>';
20382032

20392033
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20402034
});
20412035
test('multi-level blockquote with multiple spaces', () => {
20422036
const quoteTestStartString = '> > > Hello world';
2043-
const quoteTestReplacedString = '<blockquote><blockquote><blockquote> Hello world</blockquote></blockquote></blockquote>';
2037+
const quoteTestReplacedString = '<blockquote> &gt; &gt; Hello world</blockquote>';
20442038

20452039
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20462040
});
20472041

20482042
test('multi-level blockquote with mixed spaces', () => {
20492043
const quoteTestStartString = '> > > Hello world';
2050-
const quoteTestReplacedString = '<blockquote><blockquote><blockquote> Hello world</blockquote></blockquote></blockquote>';
2044+
const quoteTestReplacedString = '<blockquote> &gt; &gt; Hello world</blockquote>';
20512045

20522046
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20532047
});
20542048

20552049
test('multi-level blockquote with diffrent syntax', () => {
2056-
const quoteTestStartString = '> > _Hello_ *world*';
2050+
const quoteTestStartString = '>> _Hello_ *world*';
20572051
const quoteTestReplacedString = '<blockquote><blockquote><em>Hello</em> <strong>world</strong></blockquote></blockquote>';
20582052

20592053
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20602054
});
20612055

20622056
test('multi-level blockquote with nested heading', () => {
2063-
const quoteTestStartString = '> > # Hello world';
2057+
const quoteTestStartString = '>> # Hello world';
20642058
const quoteTestReplacedString = '<blockquote><blockquote><h1>Hello world</h1></blockquote></blockquote>';
20652059

20662060
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20672061
});
20682062

20692063
test('multiline multi-level blockquote', () => {
2070-
const quoteTestStartString = '> > Hello my\n> > beautiful\n> > world\n';
2064+
const quoteTestStartString = '>> Hello my\n>> beautiful\n>> world\n';
20712065
const quoteTestReplacedString = '<blockquote><blockquote>Hello my<br />beautiful<br />world</blockquote></blockquote>';
20722066

20732067
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);
20742068
});
20752069

20762070
test('multiline blockquote with diffrent levels', () => {
2077-
const quoteTestStartString = '> > > Hello my\n> > beautiful\n> world\n';
2071+
const quoteTestStartString = '>>> Hello my\n>> beautiful\n> world\n';
20782072
const quoteTestReplacedString = '<blockquote><blockquote><blockquote>Hello my</blockquote>beautiful</blockquote>world</blockquote>';
20792073

20802074
expect(parser.replace(quoteTestStartString)).toBe(quoteTestReplacedString);

lib/ExpensiMark.ts

Lines changed: 98 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -421,58 +421,26 @@ export default class ExpensiMark {
421421
// block quotes naturally appear on their own line. Blockquotes should not appear in code fences or
422422
// inline code blocks. A single prepending space should be stripped if it exists
423423
process: (textToProcess, replacement, shouldKeepRawInput = false) => {
424-
const regex = /^(?:&gt;)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>|<\/video>))([^\v\n\r]+)/gm;
424+
const regex = /^(?:&gt;)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>|<\/video>))([^\v\n\r]*)/gm;
425+
426+
let replacedText = this.replaceTextWithExtras(textToProcess, regex, EXTRAS_DEFAULT, replacement);
425427
if (shouldKeepRawInput) {
426-
const rawInputRegex = /^(?:&gt;)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>|<\/video>))([^\v\n\r]*)/gm;
427-
return this.replaceTextWithExtras(textToProcess, rawInputRegex, EXTRAS_DEFAULT, replacement);
428+
return replacedText;
429+
}
430+
431+
for (let i = this.maxQuoteDepth; i > 0; i--) {
432+
replacedText = replacedText.replaceAll(`${'</blockquote>'.repeat(i)}\n${'<blockquote>'.repeat(i)}`, '\n');
428433
}
429-
return this.modifyTextForQuote(regex, textToProcess, replacement as ReplacementFn);
434+
replacedText = replacedText.replaceAll('</blockquote>\n', '</blockquote>');
435+
return replacedText;
430436
},
431437
replacement: (_extras, g1) => {
432-
// We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading".
433-
// To do this we need to parse body of the quote without first space
434-
const handleMatch = (match: string) => match;
435-
const textToReplace = g1.replace(/^&gt;( )?/gm, handleMatch);
436-
const filterRules = ['heading1'];
437-
438-
// if we don't reach the max quote depth we allow the recursive call to process possible quote
439-
if (this.currentQuoteDepth < this.maxQuoteDepth - 1) {
440-
filterRules.push('quote');
441-
this.currentQuoteDepth++;
442-
}
443-
444-
const replacedText = this.replace(textToReplace, {
445-
filterRules,
446-
shouldEscapeText: false,
447-
shouldKeepRawInput: false,
448-
});
449-
this.currentQuoteDepth = 0;
450-
return `<blockquote>${replacedText}</blockquote>`;
438+
const {replacedText} = this.replaceQuoteText(g1, false);
439+
return `<blockquote>${replacedText || ' '}</blockquote>`;
451440
},
452441
rawInputReplacement: (_extras, g1) => {
453-
// We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading".
454-
// To do this we need to parse body of the quote without first space
455-
let isStartingWithSpace = false;
456-
const handleMatch = (_match: string, g2: string) => {
457-
isStartingWithSpace = !!g2;
458-
return '';
459-
};
460-
const textToReplace = g1.replace(/^&gt;( )?/gm, handleMatch);
461-
const filterRules = ['heading1'];
462-
463-
// if we don't reach the max quote depth we allow the recursive call to process possible quote
464-
if (this.currentQuoteDepth < this.maxQuoteDepth - 1 || isStartingWithSpace) {
465-
filterRules.push('quote');
466-
this.currentQuoteDepth++;
467-
}
468-
469-
const replacedText = this.replace(textToReplace, {
470-
filterRules,
471-
shouldEscapeText: false,
472-
shouldKeepRawInput: true,
473-
});
474-
this.currentQuoteDepth = 0;
475-
return `<blockquote>${isStartingWithSpace ? ' ' : ''}${replacedText}</blockquote>`;
442+
const {replacedText, shouldAddSpace} = this.replaceQuoteText(g1, true);
443+
return `<blockquote>${shouldAddSpace ? ' ' : ''}${replacedText}</blockquote>`;
476444
},
477445
},
478446
/**
@@ -1129,8 +1097,9 @@ export default class ExpensiMark {
11291097
return;
11301098
}
11311099

1100+
const nextItem = splitText?.[index + 1];
11321101
// Insert '\n' unless it ends with '\n' or '>' or it's the last element, or if it's a header ('# ') with a space.
1133-
if (text.match(/[\n|>][>]?[\s]?$/) || index === splitText.length - 1 || text === '# ') {
1102+
if ((nextItem && text.match(/>[\s]?$/) && !nextItem.startsWith('> ')) || text.match(/\n[\s]?$/) || index === splitText.length - 1 || text === '# ') {
11341103
joinedText += text;
11351104
} else {
11361105
joinedText += `${text}\n`;
@@ -1142,6 +1111,65 @@ export default class ExpensiMark {
11421111
return joinedText;
11431112
}
11441113

1114+
/**
1115+
* Unpacks nested quote HTML tags that have been packed by the 'quote' rule in this.rules for shouldKeepRawInput = false
1116+
*
1117+
* For example, it parses the following HTML:
1118+
* <blockquote>
1119+
* quote 1
1120+
* <blockquote>
1121+
* quote 2
1122+
* </blockquote>
1123+
* quote 3
1124+
* </blockquote>
1125+
*
1126+
* into:
1127+
* <blockquote> quote 1</blockquote>
1128+
* <blockquote><blockquote> quote 2</blockquote>
1129+
* <blockquote> quote 3</blockquote>
1130+
*
1131+
* Note that there will always be only a single closing tag, even if multiple opening tags exist.
1132+
* Only one closing tag is needed to detect if a nested quote has ended.
1133+
*/
1134+
unpackNestedQuotes(text: string): string {
1135+
let parsedText = text.replace(/((<\/blockquote>)+(<br \/>)?)|(<br \/>)/g, (match) => {
1136+
return `${match}</split>`;
1137+
});
1138+
const splittedText = parsedText.split('</split>');
1139+
if (splittedText.length > 0 && splittedText[splittedText.length - 1] === '') {
1140+
splittedText.pop();
1141+
}
1142+
1143+
let count = 0;
1144+
parsedText = splittedText
1145+
.map((line) => {
1146+
const hasBR = line.endsWith('<br />');
1147+
if (line === '' && count === 0) {
1148+
return '';
1149+
}
1150+
1151+
const textLine = line.replace(/(<br \/>)$/g, '');
1152+
if (textLine.startsWith('<blockquote>')) {
1153+
count += (textLine.match(/<blockquote>/g) || []).length;
1154+
}
1155+
if (textLine.endsWith('</blockquote>')) {
1156+
count -= (textLine.match(/<\/blockquote>/g) || []).length;
1157+
if (count > 0) {
1158+
return `${textLine}${'<blockquote>'.repeat(count)}`;
1159+
}
1160+
}
1161+
1162+
if (count > 0) {
1163+
return `${textLine}${'</blockquote>'}${'<blockquote>'.repeat(count)}`;
1164+
}
1165+
1166+
return textLine + (hasBR ? '<br />' : '');
1167+
})
1168+
.join('');
1169+
1170+
return parsedText;
1171+
}
1172+
11451173
/**
11461174
* Replaces HTML with markdown
11471175
*/
@@ -1154,6 +1182,7 @@ export default class ExpensiMark {
11541182
if (parseBodyTag) {
11551183
generatedMarkdown = parseBodyTag[2];
11561184
}
1185+
generatedMarkdown = this.unpackNestedQuotes(generatedMarkdown);
11571186

11581187
const processRule = (rule: RuleWithRegex) => {
11591188
// Pre-processes input HTML before applying regex
@@ -1186,91 +1215,31 @@ export default class ExpensiMark {
11861215
}
11871216

11881217
/**
1189-
* Modify text for Quotes replacing chevrons with html elements
1218+
* Main text to html 'quote' parsing logic.
1219+
* Removes &gt;( ) from text and recursively calls replace function to process nested quotes and build blockquote HTML result.
1220+
* @param shouldKeepRawInput determines if the raw input should be kept for nested quotes.
11901221
*/
1191-
modifyTextForQuote(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string {
1192-
let replacedText = '';
1193-
let textToFormat = '';
1194-
const match = textToCheck.match(regex);
1195-
1196-
// If there's matches we need to modify the quotes
1197-
if (match !== null) {
1198-
let insideCodefence = false;
1199-
1200-
// Split the textToCheck in lines
1201-
const textSplitted = textToCheck.split('\n');
1202-
1203-
for (let i = 0; i < textSplitted.length; i++) {
1204-
if (!insideCodefence) {
1205-
// We need to know when there is a start of codefence so we dont quote
1206-
insideCodefence = Str.contains(textSplitted[i], '<pre>');
1207-
}
1208-
1209-
// Since the last space will be trimmed and would incorrectly disable a condition we check it manually
1210-
const isLastBlockquote = textSplitted[i] === '&gt;' && i === textSplitted.length - 1;
1211-
1212-
// We only want to modify lines starting with "&gt; " that is not codefence
1213-
if ((Str.startsWith(textSplitted[i], '&gt; ') || isLastBlockquote) && !insideCodefence) {
1214-
if (textSplitted[i] === '&gt;') {
1215-
textToFormat += `${textSplitted[i]} \n`;
1216-
insideCodefence = true;
1217-
} else {
1218-
textToFormat += `${textSplitted[i]}\n`;
1219-
}
1220-
} else {
1221-
// Make sure we will only modify if we have Text needed to be formatted for quote
1222-
if (textToFormat !== '') {
1223-
replacedText += this.formatTextForQuote(regex, textToFormat, replacement);
1224-
textToFormat = '';
1225-
}
1226-
1227-
// We dont want a \n after the textSplitted if it is the last row
1228-
if (i === textSplitted.length - 1) {
1229-
replacedText += `${textSplitted[i]}`;
1230-
} else {
1231-
replacedText += `${textSplitted[i]}\n`;
1232-
}
1233-
1234-
// We need to know when we are not inside codefence anymore
1235-
if (insideCodefence) {
1236-
insideCodefence = !Str.contains(textSplitted[i], '</pre>');
1237-
}
1238-
}
1239-
}
1240-
1241-
// When loop ends we need the last quote to be formatted if we have quotes at last rows
1242-
if (textToFormat !== '') {
1243-
replacedText += this.formatTextForQuote(regex, textToFormat, replacement);
1244-
}
1245-
} else {
1246-
// If we doesn't have matches make sure the function will return the same textToCheck
1247-
replacedText = textToCheck;
1222+
replaceQuoteText(text: string, shouldKeepRawInput: boolean): {replacedText: string; shouldAddSpace: boolean} {
1223+
let isStartingWithSpace = false;
1224+
const handleMatch = (_match: string, g2: string) => {
1225+
isStartingWithSpace = !!g2;
1226+
return '';
1227+
};
1228+
const textToReplace = text.replace(/^&gt;( )?/gm, handleMatch);
1229+
const filterRules = ['heading1'];
1230+
// If we don't reach the max quote depth, we allow the recursive call to process other possible quotes
1231+
if (this.currentQuoteDepth < this.maxQuoteDepth - 1 && !isStartingWithSpace) {
1232+
filterRules.push('quote');
1233+
this.currentQuoteDepth++;
12481234
}
1249-
return replacedText;
1250-
}
1251-
1252-
/**
1253-
* Format the content of blockquote if the text matches the regex or else just return the original text
1254-
*/
1255-
formatTextForQuote(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string {
1256-
if (textToCheck.match(regex)) {
1257-
// Remove '&gt;' and trim the spaces between nested quotes
1258-
const formatRow = (row: string) => {
1259-
let quoteContent = row[4] === ' ' ? row.substr(5) : row.substr(4);
1260-
if (row === '&gt; ') quoteContent = row.substr(4);
1261-
1262-
if (quoteContent.trimStart().startsWith('&gt;')) {
1263-
return quoteContent.trimStart();
1264-
}
1265-
return quoteContent;
1266-
};
1267-
let textToFormat = textToCheck.split('\n').map(formatRow).join('\n');
1235+
const replacedText = this.replace(textToReplace, {
1236+
filterRules,
1237+
shouldEscapeText: false,
1238+
shouldKeepRawInput,
1239+
});
1240+
this.currentQuoteDepth = 0;
12681241

1269-
// Remove leading and trailing line breaks
1270-
textToFormat = textToFormat.replace(/^\n+|\n+$/g, '');
1271-
return replacement(EXTRAS_DEFAULT, textToFormat);
1272-
}
1273-
return textToCheck;
1242+
return {replacedText, shouldAddSpace: isStartingWithSpace};
12741243
}
12751244

12761245
/**

0 commit comments

Comments
 (0)