Repository: naturalcrit/homebrewery
Pull request #4000 (2025-01-23 06:00:32)

Parse mustache "style" properties into object instead of string
## Description
With our curly syntax, Style attributes (`{{color:red}}`) are parsed into one long string, which makes manipulating, merging, etc. a little tricky, since it involves re-parsing the string each time it needs to be handled. This is also not convenient for injection into a React component, which requires a JS object with key/value pairs for the `style` property.

HTML attributes (attr="some value") are already parsed into an object with key/value pairs.

This PR simply copies over the logic we already use for attributes into styles, so both are neatly in a key/value object format.

## Related Issues or Discussions

One large chunk of #3899 is re-parsing curly styles so they can be put into a React component. With this PR all that work is not necessary.

## QA Instructions, Screenshots, Recordings

Output is the same. Already passes all the markdown tests we have, so this should be safe to merge.



Patch:
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index c2aa89fb..99766b53 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -173,8 +173,8 @@ const mustacheSpans = {
 		return `<span` +
 			`${tags.classes    ? ` class="${tags.classes}"` : ''}` +
 			`${tags.id         ? ` id="${tags.id}"`         : ''}` +
-			`${tags.styles     ? ` style="${tags.styles}"`  : ''}` +
-			`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
+			`${tags.styles     ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
+			`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}`     : ''}` +
 			`>${this.parser.parseInline(token.tokens)}</span>`; // parseInline to turn child tokens into HTML
 	}
 };
@@ -229,7 +229,7 @@ const mustacheDivs = {
 		return `<div` +
 			`${tags.classes    ? ` class="${tags.classes}"` : ''}` +
 			`${tags.id         ? ` id="${tags.id}"`         : ''}` +
-			`${tags.styles     ? ` style="${tags.styles}"`  : ''}` +
+			`${tags.styles     ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
 			`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
 			`>${this.parser.parse(token.tokens)}</div>`; // parse to turn child tokens into HTML
 	}
@@ -272,7 +272,7 @@ const mustacheInjectInline = {
 			return `${openingTag[1]}` +
 				`${tags.classes    ? ` class="${tags.classes}"` : ''}` +
 				`${tags.id         ? ` id="${tags.id}"`         : ''}` +
-				`${tags.styles     ? ` style="${tags.styles}"`  : ''}` +
+				`${!_.isEmpty(tags.styles)     ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
 				`${!_.isEmpty(tags.attributes) ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
 				`${openingTag[2]}`; // parse to turn child tokens into HTML
 		}
@@ -316,7 +316,7 @@ const mustacheInjectBlock = {
 				return `${openingTag[1]}` +
 					`${tags.classes    ? ` class="${tags.classes}"` : ''}` +
 					`${tags.id         ? ` id="${tags.id}"`         : ''}` +
-					`${tags.styles     ? ` style="${tags.styles}"`  : ''}` +
+					`${!_.isEmpty(tags.styles)     ? ` style="${Object.entries(tags.styles).map(([key, value])=>`${key}:${value};`).join(' ')}"` : ''}` +
 					`${!_.isEmpty(tags.attributes) ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
 					`${openingTag[2]}`; // parse to turn child tokens into HTML
 			}
@@ -861,15 +861,20 @@ const processStyleTags = (string)=>{
 			const index = attr.indexOf('=');
 			let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
 			value = value.replace(/"/g, '');
-			obj[key] = value;
+			obj[key.trim()] = value.trim();
 			return obj;
 		}, {}) || null;
-	const styles     = tags?.length ? tags.map((tag)=>tag.replace(/:"?([^"]*)"?/g, ':$1;').trim()).join(' ') : null;
+	const styles = tags?.length ? tags.reduce((styleObj, style) => {
+			const index = style.indexOf(':');
+			const [key, value] = [style.substring(0, index), style.substring(index + 1)];
+			styleObj[key.trim()] = value.replace(/"?([^"]*)"?/g, '$1').trim();
+			return styleObj;
+		}, {}) : null;
 
 	return {
 		id         : id,
 		classes    : classes,
-		styles     : styles,
+		styles     : _.isEmpty(styles)     ? null : styles,
 		attributes : _.isEmpty(attributes) ? null : attributes
 	};
 };
@@ -879,21 +884,27 @@ const extractHTMLStyleTags = (htmlString)=>{
 	const firstElementOnly = htmlString.split('>')[0];
 	const id         = firstElementOnly.match(/id="([^"]*)"/)?.[1]    || null;
 	const classes    = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null;
-	const styles     = firstElementOnly.match(/style="([^"]*)"/)?.[1] || null;
+	const styles     = firstElementOnly.match(/style="([^"]*)"/)?.[1]
+		?.split(';').reduce((styleObj, style) => {
+			if (style.trim() === '') return styleObj;
+			const index = style.indexOf(':');
+			const [key, value] = [style.substring(0, index), style.substring(index + 1)];
+			styleObj[key.trim()] = value.trim();
+			return styleObj;
+		}, {}) || null;
 	const attributes = firstElementOnly.match(/[a-zA-Z]+="[^"]*"/g)
 		?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
 		.reduce((obj, attr)=>{
 			const index = attr.indexOf('=');
 			let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
-			value = value.replace(/"/g, '');
-			obj[key] = value;
+			obj[key.trim()] = value.replace(/"/g, '');
 			return obj;
 		}, {}) || null;
 
 	return {
 		id         : id,
 		classes    : classes,
-		styles     : styles,
+		styles     : _.isEmpty(styles)     ? null : styles,
 		attributes : _.isEmpty(attributes) ? null : attributes
 	};
 };
@@ -902,7 +913,7 @@ const mergeHTMLTags = (originalTags, newTags) => {
 	return {
 		id         : newTags.id || originalTags.id || null,
 		classes    : [originalTags.classes, newTags.classes].join(' ').trim() || null,
-		styles     : [originalTags.styles,  newTags.styles].join(' ').trim()  || null,
+		styles     : Object.assign(originalTags.styles     ?? {}, newTags.styles     ?? {}),
 		attributes : Object.assign(originalTags.attributes ?? {}, newTags.attributes ?? {})
 	};
 };


Test patch:
diff --git a/tests/markdown/mustache-syntax.test.js b/tests/markdown/mustache-syntax.test.js
index 261a5fd3..d1751841 100644
--- a/tests/markdown/mustache-syntax.test.js
+++ b/tests/markdown/mustache-syntax.test.js
@@ -300,7 +300,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
 		it('Renders a span "text" with its own styles, appended with injected styles', function() {
 			const source = '{{color:blue,height:10px text}}{width:10px,color:red}';
 			const rendered = Markdown.render(source);
-			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:blue; height:10px; width:10px; color:red;">text</span>');
+			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red; height:10px; width:10px;">text</span>');
 		});
 
 		it('Renders a span "text" with its own classes, appended with injected classes', function() {
@@ -429,7 +429,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
 														}}
 														{width:10px,color:red}`;
 			const rendered = Markdown.render(source).trimReturns();
-			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:blue; height:10px; width:10px; color:red;"><p>text</p></div>');
+			expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:red; height:10px; width:10px;"><p>text</p></div>');
 		});
 
 		it('Renders a span "text" with its own classes, appended with injected classes', function() {