@@ -4,14 +4,25 @@ import { fileURLToPath } from 'node:url';
44
55const TEMPLATES_DIR = fileURLToPath ( new URL ( '../templates' , import . meta. url ) ) ;
66
7+ const templateCache = new Map ( ) ;
8+
79/**
810 * Read a template file that ships with the package.
911 *
12+ * Memoized: templates don't change during a run, and these are read once per
13+ * comparison, so caching avoids hundreds of redundant blocking reads (and
14+ * filesystem contention once compare is parallelized).
15+ *
1016 * @param {string } name - The template filename.
1117 * @returns {string } The template contents.
1218 */
1319function readTemplate ( name ) {
14- return fs . readFileSync ( path . join ( TEMPLATES_DIR , name ) , 'utf8' ) ;
20+ let cached = templateCache . get ( name ) ;
21+ if ( cached === undefined ) {
22+ cached = fs . readFileSync ( path . join ( TEMPLATES_DIR , name ) , 'utf8' ) ;
23+ templateCache . set ( name , cached ) ;
24+ }
25+ return cached ;
1526}
1627
1728/**
@@ -32,15 +43,31 @@ export function copyAssets(config) {
3243 * @param {string } str - The string to escape.
3344 * @returns {string } The escaped string.
3445 */
35- function escapeHtml ( str ) {
36- return str
46+ export function escapeHtml ( str ) {
47+ return String ( str )
3748 . replace ( / & / g, '&' )
3849 . replace ( / < / g, '<' )
3950 . replace ( / > / g, '>' )
4051 . replace ( / " / g, '"' )
4152 . replace ( / ' / g, ''' ) ;
4253}
4354
55+ /**
56+ * Serialize a value for embedding in an inline <script>.
57+ *
58+ * JSON.stringify does not neutralize `</script>` or the U+2028/U+2029 line
59+ * separators, which can break out of the script element, so escape them.
60+ *
61+ * @param {* } value - The value to serialize.
62+ * @returns {string } Script-safe JSON.
63+ */
64+ function jsonForScript ( value ) {
65+ return JSON . stringify ( value )
66+ . replace ( / < / g, '\\u003c' )
67+ . replace ( / \u2028 / g, '\\u2028' )
68+ . replace ( / \u2029 / g, '\\u2029' ) ;
69+ }
70+
4471/**
4572 * Build an HTML diff report comparing two HTML snapshots.
4673 *
@@ -76,9 +103,9 @@ export function generateHtmlDiff(html1, html2, context, diffLines) {
76103
77104 const template = readTemplate ( 'html-diff.html' ) ;
78105 const html = template
79- . replaceAll ( '{name}' , name )
80- . replaceAll ( '{urlKey}' , urlKey )
81- . replaceAll ( '{viewportName}' , viewport . name )
106+ . replaceAll ( '{name}' , escapeHtml ( name ) )
107+ . replaceAll ( '{urlKey}' , escapeHtml ( urlKey ) )
108+ . replaceAll ( '{viewportName}' , escapeHtml ( viewport . name ) )
82109 . replaceAll ( '{viewportWidth}' , String ( viewport . width ) )
83110 . replaceAll ( '{viewportHeight}' , String ( viewport . height ) )
84111 . replaceAll ( '{status}' , hasChanges ? 'Changes detected' : 'No changes' )
@@ -110,11 +137,11 @@ export function generateReport(config, report) {
110137 const overlayAlt = `Original (control) capture of ${ where } ` ;
111138
112139 template = template
113- . replaceAll ( '{name}' , name )
140+ . replaceAll ( '{name}' , escapeHtml ( name ) )
114141 . replaceAll ( '{baseAlt}' , baseAlt )
115142 . replaceAll ( '{overlayAlt}' , overlayAlt )
116- . replaceAll ( '{urlKey}' , urlKey )
117- . replaceAll ( '{viewportName}' , viewport . name )
143+ . replaceAll ( '{urlKey}' , escapeHtml ( urlKey ) )
144+ . replaceAll ( '{viewportName}' , escapeHtml ( viewport . name ) )
118145 . replaceAll ( '{viewportWidth}' , String ( viewport . width ) )
119146 . replaceAll ( '{viewportHeight}' , String ( viewport . height ) )
120147 . replaceAll ( '{originalImage}' , rel ( controlImage ) )
@@ -175,38 +202,40 @@ export function generateIndex(config, reports) {
175202 ? 'medium'
176203 : 'low' ;
177204 const htmlDiffClass = report . htmlHasChanges ? 'high' : 'low' ;
205+ const url = escapeHtml ( report . url ) ;
206+ const viewportName = escapeHtml ( report . viewport . name ) ;
178207
179208 return `
180- <tr data-url="${ report . url } " data-viewport="${ report . viewport . name } " data-diff="${ report . diffPercentage } " data-index="${ index } ">
181- <td class="url-cell" title="${ report . url } ">${ report . url } </td>
182- <td>${ report . viewport . name } (${ report . viewport . width } x${ report . viewport . height } )</td>
209+ <tr data-url="${ url } " data-viewport="${ viewportName } " data-diff="${ report . diffPercentage } " data-index="${ index } ">
210+ <td class="url-cell" title="${ url } ">${ url } </td>
211+ <td>${ viewportName } (${ report . viewport . width } x${ report . viewport . height } )</td>
183212 <td class="diff-percentage ${ diffClass } "><span class="visually-hidden">${ diffClass } difference: </span>${ report . diffPercentage . toFixed ( 2 ) } %</td>
184213 <td class="diff-percentage ${ htmlDiffClass } ">${ report . htmlHasChanges ? 'Yes' : 'No' } </td>
185214 <td><a href="${ rel ( report . reportPath ) } ">View Report</a></td>
186- <td><a href="#" onclick="openModal(window.diffData, ${ index } ); return false; ">View Diff</a ></td>
215+ <td><button type="button" class="link-button" onclick="openModal(window.diffData, ${ index } , this) ">View Diff</button ></td>
187216 <td><a href="${ rel ( report . htmlDiffPath ) } ">View HTML Diff</a></td>
188217 </tr>` ;
189218 } )
190219 . join ( '' ) ;
191220
192221 const viewportOptions = viewports
193- . map (
194- ( v ) =>
195- `<option value="${ v . name } ">${ v . name } (${ v . width } x${ v . height } )</option>`
196- )
222+ . map ( ( v ) => {
223+ const vn = escapeHtml ( v . name ) ;
224+ return `<option value="${ vn } ">${ vn } (${ v . width } x${ v . height } )</option>` ;
225+ } )
197226 . join ( '\n\t\t\t\t' ) ;
198227
199228 const template = readTemplate ( 'index.html' ) ;
200229 const indexHtml = template
201- . replaceAll ( '{name}' , name )
230+ . replaceAll ( '{name}' , escapeHtml ( name ) )
202231 . replaceAll ( '{threshold}' , String ( pixelmatchOptions . threshold ) )
203232 . replaceAll ( '{includeAA}' , pixelmatchOptions . includeAA ? 'Yes' : 'No' )
204233 . replaceAll ( '{alpha}' , String ( pixelmatchOptions . alpha ) )
205234 . replaceAll ( '{diffColor}' , pixelmatchOptions . diffColor . join ( ',' ) )
206235 . replaceAll ( '{viewportOptions}' , viewportOptions )
207236 . replaceAll ( '{rows}' , rows )
208237 . replaceAll ( '{diffViewer}' , diffViewer )
209- . replaceAll ( '{diffData}' , JSON . stringify ( diffData ) ) ;
238+ . replaceAll ( '{diffData}' , jsonForScript ( diffData ) ) ;
210239
211240 const indexPath = path . join ( dirs . reports , 'index.html' ) ;
212241 fs . writeFileSync ( indexPath , indexHtml ) ;
0 commit comments