Repository: naturalcrit/homebrewery
Pull request #3648 (2024-08-19 02:55:09)

Fix for the colons in codeblocks issue.
This was the lightest-weight solution I could find without potentially breaking definition lists for the `:` in codeblocks concern.

It may be a better fix to promote this to a proper token, I am unsure.

- [x] Catches `:` series
- [x] Does not interfere with DF Lists
- [x] Does not translate inside of PRE blocks.

Solves #1469


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8a756b3d..274ec25a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -67,6 +67,9 @@ jobs:
       - run:
           name: Test - Definition Lists
           command: npm run test:definition-lists
+      - run:
+          name: Test - Hard Breaks
+          command: npm run test:hard-breaks
       - run:
           name: Test - Variables
           command: npm run test:variables
diff --git a/package.json b/package.json
index 675d76f3..a8c068f2 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
     "test:mustache-syntax:block": "jest \".*(mustache-syntax).*\" -t '^Block:.*' --verbose --noStackTrace",
     "test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
     "test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
+    "test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
     "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
     "test:route": "jest tests/routes/static-pages.test.js --verbose",
     "phb": "node scripts/phb.js",
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index be2f56af..2ee99f72 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -356,6 +356,27 @@ const superSubScripts = {
 	}
 };
 
+const forcedParagraphBreaks = {
+	name  : 'hardBreaks',
+	level : 'block',
+	start(src) { return src.match(/\n:+$/m)?.index; },  // Hint to Marked.js to stop and check for a match
+	tokenizer(src, tokens) {
+		const regex  = /^(:+)(?:\n|$)/ym;
+		const match = regex.exec(src);
+		if(match?.length) {
+			return {
+				type   : 'hardBreaks', // Should match "name" above
+				raw    : match[0],     // Text to consume from the source
+				length : match[1].length,
+				text   : ''
+			};
+		}
+	},
+	renderer(token) {
+		return `<div class='blank'></div>`.repeat(token.length).concat('\n');
+	}
+};
+
 const definitionListsSingleLine = {
 	name  : 'definitionListsSingleLine',
 	level : 'block',
@@ -400,9 +421,9 @@ const definitionListsSingleLine = {
 const definitionListsMultiLine = {
 	name  : 'definitionListsMultiLine',
 	level : 'block',
-	start(src) { return src.match(/\n[^\n]*\n::/m)?.index; },  // Hint to Marked.js to stop and check for a match
+	start(src) { return src.match(/\n[^\n]*\n::[^:\n]/m)?.index; },  // Hint to Marked.js to stop and check for a match
 	tokenizer(src, tokens) {
-		const regex = /(\n?\n?(?!::)[^\n]+?(?=\n::))|\n::(.(?:.|\n)*?(?=(?:\n::)|(?:\n\n)|$))/y;
+		const regex = /(\n?\n?(?!::)[^\n]+?(?=\n::[^:\n]))|\n::([^:\n](?:.|\n)*?(?=(?:\n::)|(?:\n\n)|$))/y;
 		let match;
 		let endIndex = 0;
 		const definitions = [];
@@ -707,7 +728,8 @@ const MarkedEmojiOptions = {
 };
 
 Marked.use(MarkedVariables());
-Marked.use({ extensions: [definitionListsMultiLine, definitionListsSingleLine, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
+Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
+	mustacheSpans, mustacheDivs, mustacheInjectInline] });
 Marked.use(mustacheInjectBlock);
 Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
 Marked.use(MarkedExtendedTables(), MarkedGFMHeadingId(), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
@@ -819,8 +841,7 @@ module.exports = {
 		varsQueue              = [];						//Could move into MarkedVariables()
 		globalPageNumber        = pageNumber;
 
-		rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`)
-														 .replace(/^(:+)$/gm, (match)=>`${`<div class='blank'></div>`.repeat(match.length)}\n`);
+		rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
 		const opts = Marked.defaults;
 
 		rawBrewText = opts.hooks.preprocess(rawBrewText);


Test patch:
diff --git a/tests/markdown/definition-lists.test.js b/tests/markdown/definition-lists.test.js
index 9f5025d7..9c0bdf6b 100644
--- a/tests/markdown/definition-lists.test.js
+++ b/tests/markdown/definition-lists.test.js
@@ -88,4 +88,16 @@ describe('Multiline Definition Lists', ()=>{
 		const rendered = Markdown.render(source).trim();
 		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt><dd>Inline definition 1</dd>\n<dt></dt><dd>Inline definition 2 (no DT)</dd>\n</dl>');
 	});
+
+	test('Multiline Definition Term must have at least one non-empty Definition', function() {
+		const source = 'Term 1\n::';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div><div class='blank'></div>`);
+	});
+
+	test('Multiline Definition List must have at least one non-newline character after ::', function() {
+		const source = 'Term 1\n::\nDefinition 1\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div><div class='blank'></div>\n<p>Definition 1</p>`);
+	});
 });
diff --git a/tests/markdown/hard-breaks.test.js b/tests/markdown/hard-breaks.test.js
new file mode 100644
index 00000000..3d0f59a4
--- /dev/null
+++ b/tests/markdown/hard-breaks.test.js
@@ -0,0 +1,47 @@
+/* eslint-disable max-lines */
+
+const Markdown = require('naturalcrit/markdown.js');
+
+describe('Hard Breaks', ()=>{
+	test('Single Break', function() {
+		const source = ':\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>`);
+	});
+
+	test('Double Break', function() {
+		const source = '::\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div>`);
+	});
+
+	test('Triple Break', function() {
+		const source = ':::\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div><div class='blank'></div>`);
+	});
+
+	test('Many Break', function() {
+		const source = '::::::::::\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div>`);
+	});
+
+	test('Multiple sets of Breaks', function() {
+		const source = ':::\n:::\n:::';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div><div class='blank'></div>\n<div class='blank'></div><div class='blank'></div><div class='blank'></div>\n<div class='blank'></div><div class='blank'></div><div class='blank'></div>`);
+	});
+
+	test('Break directly between two paragraphs', function() {
+		const source = 'Line 1\n::\nLine 2';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1</p>\n<div class='blank'></div><div class='blank'></div>\n<p>Line 2</p>`);
+	});
+
+	test('Ignored inside a code block', function() {
+		const source = '```\n\n:\n\n```\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:\n</code></pre>`);
+	});
+});