From e7984a38172164dafa34e5e14bb30e8c3befc4af Mon Sep 17 00:00:00 2001 From: VisruthSK <67435125+VisruthSK@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:32:00 -0700 Subject: [PATCH 1/4] Can deal with HTML sanitized characters now --- _extensions/flourish/flourish.js | 49 +++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/_extensions/flourish/flourish.js b/_extensions/flourish/flourish.js index b2812f7..217d894 100644 --- a/_extensions/flourish/flourish.js +++ b/_extensions/flourish/flourish.js @@ -45,7 +45,7 @@ document.addEventListener("DOMContentLoaded", () => { } // Apply flourishes with the appropriate class name - content = injectFlourishes(content, new RegExp(pattern.regex.source, 'g'), className, pattern.mask); + content = injectFlourishes(content, pattern.regex, className, pattern.mask); } el.innerHTML = content; @@ -53,6 +53,16 @@ document.addEventListener("DOMContentLoaded", () => { } }); +// Hardcode swaps for sanitized HTML +function htmlEntityEscapeForSearch(s) { + return s.replace(/&/g, '&') + .replace(//g, '>'); +} +function escapeForRegexLiteral(s) { + return RegExp.escape ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // Helper function for parsing YAML function parseDataFlourish(flourishAttr) { try { @@ -70,37 +80,42 @@ function parseDataFlourish(flourishAttr) { // pull style out and collect patterns let style = entry.style || 'default'; let mask = entry.mask || false; - let flags = key === 'target-rx' ? 'g' : undefined; + let flags = 'g'; const pats = []; for (const it of items) { if (typeof it === 'string') { - pats.push(it); - } - else if (it && it.style) { + if (key === 'target') { + const encoded = htmlEntityEscapeForSearch(it); + pats.push(escapeForRegexLiteral(encoded)); + } else { + pats.push(it); + } + } else if (it && it.style) { style = it.style; - } - else if (it && it.source) { - pats.push(it.source); + } else if (it && it.source) { + if (key === 'target') { + const encoded = htmlEntityEscapeForSearch(it.source); + pats.push(escapeForRegexLiteral(encoded)); + } else { + pats.push(it.source); + } if (it.flags) flags = it.flags; - } - else if (it && it.mask) { + } else if (it && it.mask) { mask = it.mask; } } if (pats.length) { - const re = new RegExp( - pats.map(p => `(${p})`).join('|'), - flags - ); - result.push({ type: key, regex: re, style: style, mask: mask }); + const pattern = key === 'target' ? pats.join('|') + : pats.map(p => `(${p})`).join('|'); + const re = new RegExp(pattern, flags); + result.push({ type: key, regex: re, style, mask }); } } } return result; - } - catch { + } catch { return null; } } From 6582b5163ae4cb1700780bfdb2b2906b10a6678e Mon Sep 17 00:00:00 2001 From: VisruthSK <67435125+VisruthSK@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:38:43 -0700 Subject: [PATCH 2/4] Updated documentation to reflect change to sanitization handling --- README.md | 6 ---- example.html | 82 ++++++++++++++++++++++++++++++++-------------------- example.qmd | 21 +++++++++----- 3 files changed, 64 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 4a6dc2c..99082be 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,6 @@ Let's flourish this text, that's what I mean. ::: ``` -### Bugs to fix - -* Right now, I believe it's not sanitized against rx special characters; e.g. if you want to flourish the literal character "*" that will not work. - -* In HTML, some special characters appear different, e.g. `<` appears as `<`. That means that choosing `x <- 1:10` as a flourish target won't work, because the `<` won't get matched. - ### Things you might want but we don't plan to do * Functionality for pdf or docx. This extension is written in JavaScript for HTML; any other doc formats would require a total rebuild from the ground up. diff --git a/example.html b/example.html index d430d18..fef6c07 100644 --- a/example.html +++ b/example.html @@ -2342,7 +2342,7 @@ } // Apply flourishes with the appropriate class name - content = injectFlourishes(content, new RegExp(pattern.regex.source, 'g'), className, pattern.mask); + content = injectFlourishes(content, pattern.regex, className, pattern.mask); } el.innerHTML = content; @@ -2350,6 +2350,16 @@ } }); +// Hardcode swaps for sanitized HTML +function htmlEntityEscapeForSearch(s) { + return s.replace(/&/g, '&') + .replace(//g, '>'); +} +function escapeForRegexLiteral(s) { + return RegExp.escape ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // Helper function for parsing YAML function parseDataFlourish(flourishAttr) { try { @@ -2367,37 +2377,42 @@ // pull style out and collect patterns let style = entry.style || 'default'; let mask = entry.mask || false; - let flags = key === 'target-rx' ? 'g' : undefined; + let flags = 'g'; const pats = []; for (const it of items) { if (typeof it === 'string') { - pats.push(it); - } - else if (it && it.style) { + if (key === 'target') { + const encoded = htmlEntityEscapeForSearch(it); + pats.push(escapeForRegexLiteral(encoded)); + } else { + pats.push(it); + } + } else if (it && it.style) { style = it.style; - } - else if (it && it.source) { - pats.push(it.source); + } else if (it && it.source) { + if (key === 'target') { + const encoded = htmlEntityEscapeForSearch(it.source); + pats.push(escapeForRegexLiteral(encoded)); + } else { + pats.push(it.source); + } if (it.flags) flags = it.flags; - } - else if (it && it.mask) { + } else if (it && it.mask) { mask = it.mask; } } if (pats.length) { - const re = new RegExp( - pats.map(p => `(${p})`).join('|'), - flags - ); - result.push({ type: key, regex: re, style: style, mask: mask }); + const pattern = key === 'target' ? pats.join('|') + : pats.map(p => `(${p})`).join('|'); + const re = new RegExp(pattern, flags); + result.push({ type: key, regex: re, style, mask }); } } } return result; - } - catch { + } catch { return null; } } @@ -2528,14 +2543,19 @@

Comprehensive Exampl #| - style: #| - "font-style: italic;" #| - "background-color: #468e5d;" -
+
set.seed(0)
 x <- rnorm(10)
-mean(x)
+x * 10 |> median()
+
+
 [1]  12.62954285  -3.26233361  13.29799263  12.72429321   4.14641434
+ [6] -15.39950042  -9.28567035  -2.94720447  -0.05767173  24.04653389
+
+
mean(x)
[1] 0.358924
-
sd(x)
+
sd(x)
[1] 1.205336
@@ -2544,18 +2564,18 @@

Comprehensive Exampl

Fun With CSS

Note that flourish admits (almost) any valid CSS, which allows us to do stuff like this:

-
#| flourish:
-#| - target:
-#|      - "wackyyy"
-#|      - style:
-#|            - "background-color: #bd8c51;"
-#|            - "text-decoration: line-through;"
-#|            - "-webkit-text-stroke: 1px black;"
-#|            - "filter: blur(1px);"
-#|            - "font-family: 'Brush Script MT', cursive;"
+
#| flourish:
+#| - target:
+#|      - "wackyyy"
+#|      - style:
+#|            - "background-color: #bd8c51;"
+#|            - "text-decoration: line-through;"
+#|            - "-webkit-text-stroke: 1px black;"
+#|            - "filter: blur(1px);"
+#|            - "font-family: 'Brush Script MT', cursive;"
-
wackyyy <- 1:10
-mean(wackyyy)
+
wackyyy <- 1:10
+mean(wackyyy)
[1] 5.5
diff --git a/example.qmd b/example.qmd index 7e7d2a1..e5220f8 100644 --- a/example.qmd +++ b/example.qmd @@ -118,23 +118,28 @@ This example seeks to serve as a reference for all your `flourish` needs. ``` ```{r} #| flourish: -#| - target: "mean" +#| - target: +#| - "*" +#| - "<-" +#| - "|>" #| - target: #| - "sd" #| - mask: true #| - style: "text-decoration: underline;" #| - target-rx: "[0-9]*" #| - target: -#| - "x" -#| - style: "font-weight: bold;" +#| - "mean" +#| - "x" +#| - style: "font-weight: bold;" #| - target: -#| - "set.seed" -#| - "rnorm" -#| - style: -#| - "font-style: italic;" -#| - "background-color: #468e5d;" +#| - "set.seed" +#| - "rnorm" +#| - style: +#| - "font-style: italic;" +#| - "background-color: #468e5d;" set.seed(0) x <- rnorm(10) +x * 10 |> median() mean(x) sd(x) ``` From 0075370696f10a29b209aee0d9f275cb79450527 Mon Sep 17 00:00:00 2001 From: VisruthSK <67435125+VisruthSK@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:46:11 -0700 Subject: [PATCH 3/4] Updated general flourish logic and sanitization --- _extensions/flourish/flourish.js | 26 +++++++++++++++++--------- example.html | 26 +++++++++++++++++--------- example.qmd | 20 ++++++++++++-------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/_extensions/flourish/flourish.js b/_extensions/flourish/flourish.js index 217d894..a163648 100644 --- a/_extensions/flourish/flourish.js +++ b/_extensions/flourish/flourish.js @@ -18,7 +18,11 @@ document.addEventListener("DOMContentLoaded", () => { // Find only the code chunks you care about const sourceEls = Array.from(cell.querySelectorAll('code')) - .filter(el => !el.closest('.cell-output') && !el.closest('.cell-output-stdout')); + .filter(el => + !el.closest('.cell-output') && + !el.closest('.cell-output-stdout') && + el.dataset.flourished !== 'true' // idempotent per element + ); // For each code chunk, add flourishes for (const el of sourceEls) { @@ -49,16 +53,19 @@ document.addEventListener("DOMContentLoaded", () => { } el.innerHTML = content; + el.dataset.flourished = 'true'; } } }); // Hardcode swaps for sanitized HTML function htmlEntityEscapeForSearch(s) { - return s.replace(/&/g, '&') + return s .replace(//g, '>'); + .replace(/>/g, '>') + .replace(/&(?![a-zA-Z]+;|#\d+;|#x[0-9A-Fa-f]+;)/g, '&'); } + function escapeForRegexLiteral(s) { return RegExp.escape ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -80,7 +87,7 @@ function parseDataFlourish(flourishAttr) { // pull style out and collect patterns let style = entry.style || 'default'; let mask = entry.mask || false; - let flags = 'g'; + let flags = entry.flags || 'g'; const pats = []; for (const it of items) { @@ -125,12 +132,13 @@ function addStyle(document, className = "flr-default", styleText = `background-color: yellow; display: inline; color: inherit;`) { + // Deduplicate styles by class selector + const selector = `.${className}`; + const exists = Array.from(document.querySelectorAll('style')) + .some(s => s.textContent && s.textContent.includes(selector)); + if (exists) return; const styleSheet = document.createElement("style"); - styleSheet.textContent = ` - .${className} { - ${styleText} - } -`; + styleSheet.textContent = `${selector} { ${styleText} }`; document.head.appendChild(styleSheet); } diff --git a/example.html b/example.html index fef6c07..e2a6336 100644 --- a/example.html +++ b/example.html @@ -2315,7 +2315,11 @@ // Find only the code chunks you care about const sourceEls = Array.from(cell.querySelectorAll('code')) - .filter(el => !el.closest('.cell-output') && !el.closest('.cell-output-stdout')); + .filter(el => + !el.closest('.cell-output') && + !el.closest('.cell-output-stdout') && + el.dataset.flourished !== 'true' // idempotent per element + ); // For each code chunk, add flourishes for (const el of sourceEls) { @@ -2346,16 +2350,19 @@ } el.innerHTML = content; + el.dataset.flourished = 'true'; } } }); // Hardcode swaps for sanitized HTML function htmlEntityEscapeForSearch(s) { - return s.replace(/&/g, '&') + return s .replace(//g, '>'); + .replace(/>/g, '>') + .replace(/&(?![a-zA-Z]+;|#\d+;|#x[0-9A-Fa-f]+;)/g, '&'); } + function escapeForRegexLiteral(s) { return RegExp.escape ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -2377,7 +2384,7 @@ // pull style out and collect patterns let style = entry.style || 'default'; let mask = entry.mask || false; - let flags = 'g'; + let flags = entry.flags || 'g'; const pats = []; for (const it of items) { @@ -2422,13 +2429,14 @@ styleText = `background-color: yellow; display: inline; color: inherit;`) { + // Deduplicate styles by class selector + const selector = `.${className}`; + const exists = Array.from(document.querySelectorAll('style')) + .some(s => s.textContent && s.textContent.includes(selector)); + if (exists) return; const styleSheet = document.createElement("style"); - styleSheet.textContent = ` - .${className} { - ${styleText} - } -`; + styleSheet.textContent = `${selector} { ${styleText} }`; document.head.appendChild(styleSheet); } diff --git a/example.qmd b/example.qmd index e5220f8..c8516e4 100644 --- a/example.qmd +++ b/example.qmd @@ -100,21 +100,25 @@ This example seeks to serve as a reference for all your `flourish` needs. ```{.yaml} #| flourish: -#| - target: "mean" +#| - target: +#| - "*" +#| - "<-" +#| - "|>" #| - target: #| - "sd" #| - mask: true #| - style: "text-decoration: underline;" #| - target-rx: "[0-9]*" #| - target: -#| - "x" -#| - style: "font-weight: bold;" +#| - "mean" +#| - "x" +#| - style: "font-weight: bold;" #| - target: -#| - "set.seed" -#| - "rnorm" -#| - style: -#| - "font-style: italic;" -#| - "background-color: #468e5d;" +#| - "set.seed" +#| - "rnorm" +#| - style: +#| - "font-style: italic;" +#| - "background-color: #468e5d;" ``` ```{r} #| flourish: From 443789f53042a9c7a37907275afe95eaf66d90c8 Mon Sep 17 00:00:00 2001 From: VisruthSK <67435125+VisruthSK@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:47:31 -0700 Subject: [PATCH 4/4] Fixed escaping --- _extensions/flourish/flourish.js | 23 +---- example.html | 155 ++++++++++++++++++------------- 2 files changed, 93 insertions(+), 85 deletions(-) diff --git a/_extensions/flourish/flourish.js b/_extensions/flourish/flourish.js index a163648..2133b52 100644 --- a/_extensions/flourish/flourish.js +++ b/_extensions/flourish/flourish.js @@ -61,13 +61,10 @@ document.addEventListener("DOMContentLoaded", () => { // Hardcode swaps for sanitized HTML function htmlEntityEscapeForSearch(s) { return s + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/&/g, '&') .replace(//g, '>') - .replace(/&(?![a-zA-Z]+;|#\d+;|#x[0-9A-Fa-f]+;)/g, '&'); -} - -function escapeForRegexLiteral(s) { - return RegExp.escape ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + .replace(/>/g, '>'); } // Helper function for parsing YAML @@ -92,21 +89,11 @@ function parseDataFlourish(flourishAttr) { for (const it of items) { if (typeof it === 'string') { - if (key === 'target') { - const encoded = htmlEntityEscapeForSearch(it); - pats.push(escapeForRegexLiteral(encoded)); - } else { - pats.push(it); - } + pats.push(key === 'target' ? htmlEntityEscapeForSearch(it) : it); } else if (it && it.style) { style = it.style; } else if (it && it.source) { - if (key === 'target') { - const encoded = htmlEntityEscapeForSearch(it.source); - pats.push(escapeForRegexLiteral(encoded)); - } else { - pats.push(it.source); - } + pats.push(key === 'target' ? htmlEntityEscapeForSearch(it.source) : it.source); if (it.flags) flags = it.flags; } else if (it && it.mask) { mask = it.mask; diff --git a/example.html b/example.html index e2a6336..067e026 100644 --- a/example.html +++ b/example.html @@ -2,7 +2,7 @@ - + @@ -65,8 +65,9 @@ * Licensed MIT © Zeno Rocha */ !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return b}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),r=n.n(e);function c(t){try{return document.execCommand(t)}catch(t){return}}var a=function(t){t=r()(t);return c("cut"),t};function o(t,e){var n,o,t=(n=t,o="rtl"===document.documentElement.getAttribute("dir"),(t=document.createElement("textarea")).style.fontSize="12pt",t.style.border="0",t.style.padding="0",t.style.margin="0",t.style.position="absolute",t.style[o?"right":"left"]="-9999px",o=window.pageYOffset||document.documentElement.scrollTop,t.style.top="".concat(o,"px"),t.setAttribute("readonly",""),t.value=n,t);return e.container.appendChild(t),e=r()(t),c("copy"),t.remove(),e}var f=function(t){var e=1 - + +