1+ import { createHash } from 'node:crypto' ;
12import { AXE_TO_FPC , FPC_LABELS , FPC_SVGS , FPC_DESCRIPTIONS } from '../data/axe-fpc-mapping.js' ;
23import { getFpcPrevalenceRates , CENSUS_DISABILITY_STATS } from '../data/census-disability-stats.js' ;
34import { getPolicyNarrative , getHeuristicsForAxeRule } from '../data/axe-impact-loader.js' ;
@@ -23,6 +24,30 @@ function formatCompact(n) {
2324 return String ( n ) ;
2425}
2526
27+ /**
28+ * Generates a stable unique identifier for an accessibility violation.
29+ * The ID is based on the normalized URL, rule ID, and element selector so that
30+ * the same violation on the same element produces the same ID across scans,
31+ * enabling tracking of how long an issue has persisted.
32+ *
33+ * @param {string } pageUrl - The URL of the scanned page
34+ * @param {string } ruleId - The axe rule ID (e.g. "color-contrast")
35+ * @param {string } [selector] - The CSS selector of the affected element (empty string for finding-level ID)
36+ * @returns {string } A short stable identifier, e.g. "DAP-a1b2c3d4"
37+ */
38+ export function generateViolationId ( pageUrl , ruleId , selector = '' ) {
39+ const safeUrl = typeof pageUrl === 'string' ? pageUrl : '' ;
40+ const safeRuleId = typeof ruleId === 'string' ? ruleId : '' ;
41+ const safeSelector = typeof selector === 'string' ? selector : '' ;
42+ const normalizedUrl = safeUrl
43+ . replace ( / ^ h t t p s ? : \/ \/ / i, '' )
44+ . replace ( / \/ + $ / , '' )
45+ . toLowerCase ( ) ;
46+ const seed = `${ normalizedUrl } |${ safeRuleId } |${ safeSelector } ` ;
47+ const hash = createHash ( 'sha256' ) . update ( seed ) . digest ( 'hex' ) . slice ( 0 , 8 ) ;
48+ return `DAP-${ hash } ` ;
49+ }
50+
2651/**
2752 * Format a byte count as a human-readable string (B / KB / MB / GB).
2853 *
@@ -606,6 +631,17 @@ function renderSharedStyles() {
606631 overflow-x: auto;
607632 font-size: 0.85em;
608633 }
634+ .violation-id {
635+ font-size: 0.75em;
636+ font-family: monospace;
637+ color: var(--color-muted, #6b7280);
638+ background: var(--color-code-bg, #f3f4f6);
639+ border: 1px solid var(--color-border, #d1d5db);
640+ border-radius: 3px;
641+ padding: 0.1em 0.35em;
642+ vertical-align: middle;
643+ user-select: all;
644+ }
609645 .finding-detail {
610646 padding: 0.5rem 1rem;
611647 border: 1px solid var(--color-finding-border);
@@ -1794,21 +1830,26 @@ function renderWcagTags(tags = []) {
17941830 return `<p class="wcag-tags"><strong>WCAG criteria:</strong> ${ wcagLabels . map ( ( l ) => escapeHtml ( l ) ) . join ( ', ' ) } </p>` ;
17951831}
17961832
1797- function renderAxeFindingItems ( items = [ ] ) {
1833+ function renderAxeFindingItems ( items = [ ] , pageUrl = '' , ruleId = '' ) {
17981834 if ( items . length === 0 ) {
17991835 return '<p><em>No specific element details available.</em></p>' ;
18001836 }
18011837
18021838 return items
18031839 . map (
1804- ( item , index ) => `
1840+ ( item , index ) => {
1841+ const elementId = typeof pageUrl === 'string' && pageUrl . length > 0 && typeof ruleId === 'string' && ruleId . length > 0
1842+ ? generateViolationId ( pageUrl , ruleId , item . selector ?? '' )
1843+ : '' ;
1844+ return `
18051845 <div class="axe-item">
1806- <p><strong>Element ${ index + 1 } </strong></p>
1846+ <p><strong>Element ${ index + 1 } </strong>${ elementId ? ` <code class="violation-id" title="Stable identifier for this accessibility violation"> ${ escapeHtml ( elementId ) } </code>` : '' } </p>
18071847 ${ item . selector ? `<p><strong>Element path:</strong> <code>${ escapeHtml ( item . selector ) } </code></p>` : '' }
18081848 ${ item . snippet ? `<p><strong>Snippet:</strong></p><pre><code>${ escapeHtml ( item . snippet ) } </code></pre>` : '' }
18091849 ${ item . node_label && item . node_label !== item . selector ? `<p><strong>Label:</strong> ${ escapeHtml ( item . node_label ) } </p>` : '' }
18101850 ${ renderExplanationHtml ( item . explanation ) }
1811- </div>`
1851+ </div>` ;
1852+ }
18121853 )
18131854 . join ( '\n' ) ;
18141855}
@@ -1853,10 +1894,12 @@ function formatImpactList(items) {
18531894export function buildFindingCopyText ( pageUrl , finding , pageLoadCount = 0 , scanDate = '' ) {
18541895 const wcagLabels = ( finding . tags ?? [ ] ) . map ( formatWcagTag ) . filter ( Boolean ) ;
18551896 const fpcCodes = AXE_TO_FPC . get ( finding . id ) ?? [ ] ;
1897+ const findingId = generateViolationId ( pageUrl , finding . id , '' ) ;
18561898 const lines = [
18571899 `**URL:** ${ pageUrl } ` ,
18581900 '' ,
18591901 `**${ finding . title } ** (rule: \`${ finding . id } \`)` ,
1902+ `**Violation ID:** ${ findingId } ` ,
18601903 '' ,
18611904 plainTextDescription ( finding . description ?? '' ) ,
18621905 ] ;
@@ -1891,7 +1934,8 @@ export function buildFindingCopyText(pageUrl, finding, pageLoadCount = 0, scanDa
18911934 lines . push ( '' , `**Affected elements (${ items . length } ):**` ) ;
18921935
18931936 items . forEach ( ( item , index ) => {
1894- lines . push ( '' , `**Element ${ index + 1 } **` ) ;
1937+ const elementId = generateViolationId ( pageUrl , finding . id , item . selector ?? '' ) ;
1938+ lines . push ( '' , `**Element ${ index + 1 } ** (ID: ${ elementId } )` ) ;
18951939 if ( item . selector ) {
18961940 lines . push ( `Element path: \`${ item . selector } \`` ) ;
18971941 }
@@ -1922,15 +1966,16 @@ function renderAxeFindingsList(axeFindings = [], pageUrl = '', pageLoadCount = 0
19221966 fpcCodes && fpcCodes . length > 0
19231967 ? `<p><strong>Disabilities affected:</strong> ${ renderFpcCodes ( finding . id , pageLoadCount , prevalenceRates ) } </p>`
19241968 : '' ;
1969+ const findingId = generateViolationId ( pageUrl , finding . id , '' ) ;
19251970 return `
19261971 <details>
1927- <summary><strong>${ escapeHtml ( finding . title ) } </strong> (rule: <code>${ escapeHtml ( finding . id ) } </code>)</summary>
1972+ <summary><strong>${ escapeHtml ( finding . title ) } </strong> (rule: <code>${ escapeHtml ( finding . id ) } </code>) <code class="violation-id" title="Stable identifier for this accessibility finding"> ${ escapeHtml ( findingId ) } </code> </summary>
19281973 <div class="finding-detail">
19291974 <p>${ renderDescriptionHtml ( finding . description ) } </p>
19301975 ${ renderWcagTags ( finding . tags ) }
19311976 ${ fpcHtml }
19321977 <p><strong>Affected elements (${ finding . items . length } ):</strong></p>
1933- ${ renderAxeFindingItems ( finding . items ) }
1978+ ${ renderAxeFindingItems ( finding . items , pageUrl , finding . id ) }
19341979 <button class="copy-finding-btn" data-copy-text="${ escapeHtml ( buildFindingCopyText ( pageUrl , finding , pageLoadCount , scanDate ) ) } " aria-label="Copy finding to clipboard">Copy finding</button>
19351980 </div>
19361981 </details>` ;
0 commit comments