Repository: naturalcrit/homebrewery
Pull request #3909 (2024-11-23 17:36:09)

V4 proposed non-breaking space token
## Description

Implements `:>`  for a series of non-breaking spaces analogous to `:` based hard breaks per V4 proposed discussion.

This serves to evaluate the utility of this feature.

## Related Issues or Discussions

Relates to discussion #2879 

## QA Instructions, Screenshots, Recordings

Implements as an inline token. Use `:` followed by one `>` for each non-breaking space.
### Reviewer Checklist

_Please replace the list below with specific features you want reviewers to look at._

*Reviewers, refer to this list when testing features, or suggest new items *
- [ ] Verify new features are functional
  - [ ] Test any number of `>` after the `:` 
- [ ] Verify old features have not broken
  - [ ] Verify it doesn't get parsed in code blocks.
- [ ] Test for edge cases / try to break things
  - [ ] ??
- [ ] Identify opportunities for simplification and refactoring
- [ ] Check for code legibility and appropriate comments




Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 10264d4d..2025e8fe 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -70,6 +70,9 @@ jobs:
       - run:
           name: Test - Hard Breaks
           command: npm run test:hard-breaks
+      - run:
+          name: Test - Non-Breaking Spaces
+          command: npm run test:non-breaking-spaces
       - run:
           name: Test - Variables
           command: npm run test:variables
diff --git a/package.json b/package.json
index aec34b08..2ce79f11 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "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:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace",
     "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
     "test:route": "jest tests/routes/static-pages.test.js --verbose",
     "test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index 4c1a2f92..852243d8 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -391,10 +391,31 @@ const forcedParagraphBreaks = {
 	}
 };
 
+const nonbreakingSpaces = {
+	name  : 'nonbreakingSpaces',
+	level : 'inline',
+	start(src) { return src.match(/:>+/m)?.index; },  // Hint to Marked.js to stop and check for a match
+	tokenizer(src, tokens) {
+		const regex  = /:(>+)/ym;
+		const match = regex.exec(src);
+		if(match?.length) {
+			return {
+				type   : 'nonbreakingSpaces', // Should match "name" above
+				raw    : match[0],     // Text to consume from the source
+				length : match[1].length,
+				text   : ''
+			};
+		}
+	},
+	renderer(token) {
+		return `&nbsp;`.repeat(token.length).concat('');
+	}
+};
+
 const definitionListsSingleLine = {
 	name  : 'definitionListsSingleLine',
 	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]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
 	tokenizer(src, tokens) {
 		const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
 		let match;
@@ -748,11 +769,12 @@ const tableTerminators = [
 ];
 
 Marked.use(MarkedVariables());
-Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
-	mustacheSpans, mustacheDivs, mustacheInjectInline] });
+Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks,
+	nonbreakingSpaces, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
 Marked.use(mustacheInjectBlock);
 Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
-Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
+Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }),
+	MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
 
 function cleanUrl(href) {
 	try {


Test patch:
diff --git a/tests/markdown/non-breaking-spaces.test.js b/tests/markdown/non-breaking-spaces.test.js
new file mode 100644
index 00000000..9dad4eb0
--- /dev/null
+++ b/tests/markdown/non-breaking-spaces.test.js
@@ -0,0 +1,72 @@
+/* eslint-disable max-lines */
+
+import Markdown from 'naturalcrit/markdown.js';
+
+describe('Non-Breaking Spaces', ()=>{
+	test('Single Space', function() {
+		const source = ':>\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;</p>`);
+	});
+
+	test('Double Space', function() {
+		const source = ':>>\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;</p>`);
+	});
+
+	test('Triple Space', function() {
+		const source = ':>>>\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;</p>`);
+	});
+
+	test('Many Space', function() {
+		const source = ':>>>>>>>>>>\n\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
+	});
+
+	test('Multiple sets of Spaces', function() {
+		const source = ':>>>\n:>>>\n:>>>';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;</p>`);
+	});
+
+	test('Pair of inline Spaces', function() {
+		const source = ':>>:>>';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
+	});
+
+	test('Space 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\n&nbsp;&nbsp;\nLine 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:&gt;\n</code></pre>`);
+	});
+
+	test('I am actually a single-line definition list!', function() {
+		const source = 'Term ::> Definition 1\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt><dd>> Definition 1</dd>\n</dl>`);
+	});
+
+	test('I am actually a definition list!', function() {
+		const source = 'Term\n::> Definition 1\n';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd></dl>`);
+	});
+
+	test('I am actually a two-term definition list!', function() {
+		const source = 'Term\n::> Definition 1\n::>> Definition 2';
+		const rendered = Markdown.render(source).trim();
+		expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd>\n<dd>>> Definition 2</dd></dl>`);
+	});
+});
+