Repository: naturalcrit/homebrewery
Pull request #3129 (2023-11-07 04:33:00)

Add Multi-line Dictionary Definition (<dd>) rows.
Expands the existing syntax to allow/expect the option of multiple definitions by adding any number of ``` :: Definition``` to an a DT/DD set.

Solves #2340.

**Final Syntax and Behavior**

This has been updated to a multi-description ( DD ) mode that can take multi-line input. This mode expects the term on one line, the description(s) on lines after, then a blank line before a new term. Two blank lines terminate the list.

This cannot be mixed with the existing inline style definition lists.

Example:

```
Term 1
:: Definition 1
:: Definition 2
is on multiple lines

Term 2:
:: Definition 1


```
Test case Brew at: https://homebrewery.naturalcrit.com/share/h-sHSQsRyUlz


Patch:
diff --git a/client/homebrew/editor/editor.jsx b/client/homebrew/editor/editor.jsx
index 4f3ef44f..a9efdb24 100644
--- a/client/homebrew/editor/editor.jsx
+++ b/client/homebrew/editor/editor.jsx
@@ -151,12 +151,19 @@ const Editor = createClass({
 
 						// definition lists
 						if(line.includes('::')){
-							const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
+							if(/^:*$/.test(line) == true){ return };
+							const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd;  // the `d` flag, for match indices, throws an ESLint error.
 							let match;
 							while ((match = regex.exec(line)) != null){
-								codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[0]) }, { line: lineNumber, ch: line.indexOf(match[0]) + match[0].length }, { className: 'define' });
-								codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'term' });
-								codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[2]) }, { line: lineNumber, ch: line.indexOf(match[2]) + match[2].length }, { className: 'definition' });
+								codeMirror.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
+								codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
+								codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
+								const ddIndex = match.indices[2][0];
+								let colons = /::/g;
+								let colonMatches = colons.exec(match[2]);
+								if(colonMatches !== null){
+									codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight'} )
+								}
 							}
 						}
 
diff --git a/client/homebrew/editor/editor.less b/client/homebrew/editor/editor.less
index b165f91d..d7950ead 100644
--- a/client/homebrew/editor/editor.less
+++ b/client/homebrew/editor/editor.less
@@ -55,6 +55,16 @@
 			vertical-align : sub;
 			font-size      : 0.9em;
 		}
+		.dl-highlight {
+			&.dl-colon-highlight {
+				font-weight   : bold;
+				color         : #949494;
+				background    : #E5E5E5;
+				border-radius : 3px;
+			}
+			&.dt-highlight { color : rgb(96, 117, 143); }
+			&.dd-highlight { color : rgb(97, 57, 178); }
+		}		
 	}
 
 	.brewJump {
diff --git a/package.json b/package.json
index e0c65e30..0cf6fe77 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
     "test:mustache-syntax:inline": "jest '.*(mustache-syntax).*' -t '^Inline:.*' --verbose --noStackTrace",
     "test:mustache-syntax:block": "jest '.*(mustache-syntax).*' -t '^Block:.*' --verbose --noStackTrace",
     "test:mustache-syntax:injection": "jest '.*(mustache-syntax).*' -t '^Injection:.*' --verbose --noStackTrace",
+    "test:marked-extensions": "jest tests/markdown/marked-extensions.test.js --verbose --noStackTrace", 
     "test:route": "jest tests/routes/static-pages.test.js --verbose",
     "phb": "node scripts/phb.js",
     "prod": "set NODE_ENV=production && npm run build",
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index 09f81090..f82ec3c3 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -294,10 +294,10 @@ const superSubScripts = {
 	}
 };
 
-const definitionLists = {
-	name  : 'definitionLists',
+const definitionListsInline = {
+	name  : 'definitionListsInline',
 	level : 'block',
-	start(src) { return src.match(/^.*?::.*/m)?.index; },  // Hint to Marked.js to stop and check for a match
+	start(src) { return src.match(/^[^\n]*?::[^\n]*/m)?.index; },  // Hint to Marked.js to stop and check for a match
 	tokenizer(src, tokens) {
 		const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
 		let match;
@@ -312,7 +312,7 @@ const definitionLists = {
 		}
 		if(definitions.length) {
 			return {
-				type : 'definitionLists',
+				type : 'definitionListsInline',
 				raw  : src.slice(0, endIndex),
 				definitions
 			};
@@ -321,11 +321,54 @@ const definitionLists = {
 	renderer(token) {
 		return `<dl>${token.definitions.reduce((html, def)=>{
 			return `${html}<dt>${this.parser.parseInline(def.dt)}</dt>`
-			            + `<dd>${this.parser.parseInline(def.dd)}</dd>\n`;
+			            + `<dd>${this.parser.parseInline(def.dd)}</dd>`;
 		}, '')}</dl>`;
 	}
 };
 
+const definitionListsMultiline = {
+	name  : 'definitionListsMultiline',
+	level : 'block',
+	start(src) { return src.match(/^[^\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;
+		let match;
+		let endIndex = 0;
+		const definitions = [];
+		while (match = regex.exec(src)) {
+			if(match[1]) {
+				definitions.push({
+					dt  : this.lexer.inlineTokens(match[1].trim()),
+					dds : []
+				});
+			}
+			if(match[2]) {
+				definitions[definitions.length - 1].dds.push(
+					this.lexer.inlineTokens(match[2].trim().replace(/\s/g, ' '))
+				);
+			}
+			endIndex = regex.lastIndex;
+		}
+		if(definitions.length) {
+			return {
+				type : 'definitionListsMultiline',
+				raw  : src.slice(0, endIndex),
+				definitions
+			};
+		}
+	},
+	renderer(token) {
+		let returnVal = `<dl>`;
+		token.definitions.forEach((def)=>{
+			const dds = def.dds.map((s)=>{
+				return `\n<dd>${this.parser.parseInline(s).trim()}</dd>`;
+			}).join('');
+			returnVal += `<dt>${this.parser.parseInline(def.dt)}</dt>${dds}\n`;
+		});
+		returnVal = returnVal.trim();
+		return `${returnVal}</dl>`;
+	}
+};
 
 //v=====--------------------< Variable Handling >-------------------=====v// 242 lines
 const replaceVar = function(input, hoist=false, allowUnresolved=false) {
@@ -572,7 +615,7 @@ function MarkedVariables() {
 //^=====--------------------< Variable Handling >-------------------=====^//
 
 Marked.use(MarkedVariables());
-Marked.use({ extensions: [mustacheSpans, mustacheDivs, mustacheInjectInline, definitionLists, superSubScripts] });
+Marked.use({ extensions: [mustacheSpans, mustacheDivs, mustacheInjectInline, definitionListsInline, definitionListsMultiline, superSubScripts] });
 Marked.use(mustacheInjectBlock);
 Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
 Marked.use(MarkedExtendedTables(), MarkedGFMHeadingId(), MarkedSmartypantsLite());


Test patch:
diff --git a/tests/markdown/marked-extensions.test.js b/tests/markdown/marked-extensions.test.js
new file mode 100644
index 00000000..8d6c3c1c
--- /dev/null
+++ b/tests/markdown/marked-extensions.test.js
@@ -0,0 +1,79 @@
+/* eslint-disable max-lines */
+
+const Markdown = require('naturalcrit/markdown.js');
+
+describe('Inline Definition Lists', ()=>{
+	test('No Term 1 Definition', function() {
+		const source = ':: My First Definition\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt></dt><dd>My First Definition</dd></dl>');
+	});
+
+	test('Single Definition Term', function() {
+		const source = 'My term :: My First Definition\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>My term</dt><dd>My First Definition</dd></dl>');
+	});
+
+	test('Multiple Definition Terms', function() {
+		const source = 'Term 1::Definition of Term 1\nTerm 2::Definition of Term 2\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt><dd>Definition of Term 1</dd><dt>Term 2</dt><dd>Definition of Term 2</dd></dl>');
+	});
+});
+
+describe('Multiline Definition Lists', ()=>{
+	test('Single Term, Single Definition', function() {
+		const source = 'Term 1\n::Definition 1\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd></dl>');
+	});
+
+	test('Single Term, Plural Definitions', function() {
+		const source = 'Term 1\n::Definition 1\n::Definition 2\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
+	});
+
+	test('Multiple Term, Single Definitions', function() {
+		const source = 'Term 1\n::Definition 1\n\nTerm 2\n::Definition 1\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd></dl>');
+	});
+
+	test('Multiple Term, Plural Definitions', function() {
+		const source = 'Term 1\n::Definition 1\n::Definition 2\n\nTerm 2\n::Definition 1\n::Definition 2\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
+	});
+
+	test('Single Term, Single multi-line definition', function() {
+		const source = 'Term 1\n::Definition 1\nand more and\nmore and more\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more and more</dd></dl>');
+	});
+
+	test('Single Term, Plural multi-line definitions', function() {
+		const source = 'Term 1\n::Definition 1\nand more and more\n::Definition 2\nand more\nand more\n::Definition 3\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dd>Definition 2 and more and more</dd>\n<dd>Definition 3</dd></dl>');
+	});
+
+	test('Multiple Term, Single multi-line definition', function() {
+		const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
+	});
+
+	test('Multiple Term, Single multi-line definition, followed by an inline dl', function() {
+		const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\n::Inline Definition (no term)';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl><dl><dt></dt><dd>Inline Definition (no term)</dd></dl>');
+	});
+
+	test('Multiple Term, Single multi-line definition, followed by paragraph', function() {
+		const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\nParagraph';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl><p>Paragraph</p>');
+	});
+});