From d276fd54ae5a2da143b11cf2a0c5cd438345ced9 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 3 Nov 2025 23:08:45 +0100 Subject: [PATCH 1/2] fix: avoid mutations so lib can be used in a Svelte `$derived()` --- src/lib/chunkify.ts | 9 ++- src/lib/decuplicate.ts | 120 +++++++++++---------------------- src/lib/extend-ranges.test.ts | 96 ++++++++++++-------------- src/lib/extend-ranges.ts | 91 +++++++++++++------------ src/lib/filter-entries.test.ts | 10 +-- src/lib/filter-entries.ts | 62 ++++++++--------- src/lib/index.ts | 17 +---- src/lib/prettify.ts | 7 +- 8 files changed, 173 insertions(+), 239 deletions(-) diff --git a/src/lib/chunkify.ts b/src/lib/chunkify.ts index 31278d0..d1466c9 100644 --- a/src/lib/chunkify.ts +++ b/src/lib/chunkify.ts @@ -10,6 +10,8 @@ export type ChunkedCoverage = Omit & { chunks: Chunk[] } +const WHITESPACE_ONLY_REGEX = /^\s+$/ + function merge(stylesheet: ChunkedCoverage): ChunkedCoverage { let new_chunks: Chunk[] = [] let previous_chunk: Chunk | undefined @@ -18,7 +20,7 @@ function merge(stylesheet: ChunkedCoverage): ChunkedCoverage { let chunk = stylesheet.chunks.at(i)! // If the current chunk is only whitespace or empty, ignore it - if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) { + if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) { continue } @@ -32,7 +34,10 @@ function merge(stylesheet: ChunkedCoverage): ChunkedCoverage { continue } // If the current chunk is only whitespace or empty, add it to the previous - else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) { + else if ( + WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || + chunk.end_offset === chunk.start_offset + ) { latest_chunk.end_offset = chunk.end_offset // do not update previous_chunk continue diff --git a/src/lib/decuplicate.ts b/src/lib/decuplicate.ts index 3fec957..b423509 100644 --- a/src/lib/decuplicate.ts +++ b/src/lib/decuplicate.ts @@ -1,104 +1,60 @@ import type { Coverage, Range } from './parse-coverage.js' -// Combine multiple adjecent ranges into a single one -function concatenate(ranges: Set | Range[]): Range[] { - let result: Range[] = [] +// 1. Merge and concatenate ranges +function merge_ranges(ranges: Range[]): Range[] { + if (ranges.length === 0) return [] - for (let range of ranges) { - // Update the last range if this range starts at last-range-end + 1 - if (result.length > 0 && (result.at(-1)!.end === range.start - 1 || result.at(-1)!.end === range.start)) { - result.at(-1)!.end = range.end + // sort by start + ranges.sort((a, b) => a.start - b.start) + + let merged: Range[] = [ranges[0]!] + + for (let r of ranges.slice(1)) { + let last = merged.at(-1) + + // merge overlapping or adjacent + if (last && r.start <= last.end + 1) { + if (r.end > last.end) { + last.end = r.end + } } else { - result.push(range) + merged.push({ start: r.start, end: r.end }) } } - return result + return merged } -function dedupe_list(ranges: Range[]): Set { - let new_ranges: Set = new Set() +// 2. Merge ranges for a single stylesheet entry into an existing grouped sheet +function merge_entry_ranges(sheet: { url: string; ranges: Range[] } | undefined, entry: Coverage): { url: string; ranges: Range[] } { + if (!sheet) { + return { url: entry.url, ranges: [...entry.ranges] } + } - outer: for (let range of ranges) { - for (let processed_range of new_ranges) { - // Case: an existing range fits within this range -> replace it - // { start: 0, end: 100 }, - // { start: 0, end: 200 } - if (range.start <= processed_range.start && range.end >= processed_range.end) { - new_ranges.delete(processed_range) - new_ranges.add(range) - continue outer - } + let seen = new Set(sheet.ranges.map((r) => `${r.start}:${r.end}`)) - // Case: this range fits within an existing range -> skip it - // { start: 324, end: 485 }, --> exists - // { start: 364, end: 485 }, --> skip - // { start: 404, end: 485 }, --> skip - if (range.start >= processed_range.start && range.end <= processed_range.end) { - // console.log('skip', range) - continue outer - } - // Case: ranges partially overlap - // { start: 324, end: 444 }, - // { start: 364, end: 485 }, - if (range.start < processed_range.end && range.start > processed_range.start && range.end > processed_range.end) { - new_ranges.delete(processed_range) - new_ranges.add({ - start: processed_range.start, - end: range.end, - }) - continue outer - } + for (let range of entry.ranges) { + let id = `${range.start}:${range.end}` + if (!seen.has(id)) { + seen.add(id) + sheet.ranges.push({ ...range }) } - new_ranges.add(range) } - return new_ranges + return sheet } -/** - * @description - * prerequisites - * - we check each stylesheet content only once (to avoid counting the same content multiple times) - * - if a duplicate stylesheet enters the room, we add it's ranges to the existing stylesheet's ranges - * - only bytes of deduplicated stylesheets are counted - */ +// 3. Main function orchestrating the grouping and range merging export function deduplicate_entries(entries: Coverage[]): Coverage[] { - let checked_stylesheets = new Map() - - for (let entry of entries) { - let text = entry.text - if (checked_stylesheets.has(text)) { - let sheet = checked_stylesheets.get(text)! - let ranges = sheet.ranges - // Check if the ranges are already in the checked_stylesheets map - // If not, add them - for (let range of entry.ranges) { - let found = false - - for (let checked_range of ranges) { - // find exact range - if (checked_range.start === range.start && checked_range.end === range.end) { - found = true - break - } - } - - if (!found) { - ranges.push(range) - } - } - } else { - checked_stylesheets.set(text, { - url: entry.url, - ranges: entry.ranges, - }) - } - } + let grouped = entries.reduce>((acc, entry) => { + let key = entry.text + acc[key] = merge_entry_ranges(acc[key], entry) + return acc + }, Object.create(null)) - return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({ + return Object.entries(grouped).map(([text, { url, ranges }]) => ({ text, url, - ranges: concatenate(dedupe_list(ranges.sort((a, b) => a.start - b.start))).sort((a, b) => a.start - b.start), + ranges: merge_ranges(ranges), })) } diff --git a/src/lib/extend-ranges.test.ts b/src/lib/extend-ranges.test.ts index d25dd6d..3ec96bb 100644 --- a/src/lib/extend-ranges.test.ts +++ b/src/lib/extend-ranges.test.ts @@ -24,8 +24,8 @@ test.describe('leaves ranges intact when nothing to change', () => { // Expect the incomplete coverage reported by the browser expect(coverage.at(0)!.ranges).toEqual([{ start: 0, end: 17 }]) - let result = extend_ranges(coverage) - expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 17 }]) + let result = extend_ranges(coverage[0]) + expect(result.ranges).toEqual([{ start: 0, end: 17 }]) }) }) @@ -37,8 +37,8 @@ test.describe('@rules', () => { // Expect the incomplete coverage reported by the browser expect(coverage.at(0)!.ranges).toEqual([{ start: 7, end: 42 }]) // (min-width:100px){body{color:green} - let result = extend_ranges(coverage) - expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} + let result = extend_ranges(coverage[0]) + expect(result.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} }) test.describe('adjecent to uncovered code', () => { @@ -49,8 +49,8 @@ test.describe('@rules', () => { // Expect the incomplete coverage reported by the browser expect(coverage.at(0)!.ranges).toEqual([{ start: 10, end: 45 }]) // (min-width:100px){body{color:green} - let result = extend_ranges(coverage) - expect(result.at(0)!.ranges).toEqual([{ start: 3, end: 46 }]) // @media (min-width:100px){body{color:green}} + let result = extend_ranges(coverage[0]) + expect(result.ranges).toEqual([{ start: 3, end: 46 }]) // @media (min-width:100px){body{color:green}} }) test('@media at start', async () => { @@ -60,8 +60,8 @@ test.describe('@rules', () => { // Expect the incomplete coverage reported by the browser expect(coverage.at(0)!.ranges).toEqual([{ start: 7, end: 42 }]) // (min-width:100px){body{color:green} - let result = extend_ranges(coverage) - expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} + let result = extend_ranges(coverage[0]) + expect(result.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} }) }) @@ -71,28 +71,24 @@ test.describe('@rules', () => { let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] // Expect the incomplete coverage reported by the browser - expect(coverage).toEqual([ - { - url: 'http://localhost/style.css', - text: css, - ranges: [ - { start: 0, end: 12 }, // p{color:red} - { start: 19, end: 54 }, // (min-width:100px){body{color:green} - ], - }, - ]) - - let result = extend_ranges(coverage) - expect(result).toEqual([ - { - url: 'http://localhost/style.css', - text: css, - ranges: [ - { start: 0, end: 12 }, // p{color:red} - { start: 12, end: 55 }, // @media (min-width:100px){body{color:green}} - ], - }, - ]) + expect(coverage[0]).toEqual({ + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 12 }, // p{color:red} + { start: 19, end: 54 }, // (min-width:100px){body{color:green} + ], + }) + + let result = extend_ranges(coverage[0]) + expect(result).toEqual({ + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 12 }, // p{color:red} + { start: 12, end: 55 }, // @media (min-width:100px){body{color:green}} + ], + }) }) test('@media at start', async () => { @@ -100,28 +96,24 @@ test.describe('@rules', () => { let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] // Expect the incomplete coverage reported by the browser - expect(coverage).toEqual([ - { - url: 'http://localhost/style.css', - text: css, - ranges: [ - { start: 7, end: 42 }, // (min-width:100px){body{color:green} - { start: 43, end: 55 }, // p{color:red} - ], - }, - ]) - - let result = extend_ranges(coverage) - expect(result).toEqual([ - { - url: 'http://localhost/style.css', - text: css, - ranges: [ - { start: 0, end: 43 }, // @media (min-width:100px){body{color:green}} - { start: 43, end: 55 }, // p{color:red} - ], - }, - ]) + expect(coverage[0]).toEqual({ + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 7, end: 42 }, // (min-width:100px){body{color:green} + { start: 43, end: 55 }, // p{color:red} + ], + }) + + let result = extend_ranges(coverage[0]) + expect(result).toEqual({ + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 43 }, // @media (min-width:100px){body{color:green}} + { start: 43, end: 55 }, // p{color:red} + ], + }) }) }) }) diff --git a/src/lib/extend-ranges.ts b/src/lib/extend-ranges.ts index afd6e48..c4ddaf8 100644 --- a/src/lib/extend-ranges.ts +++ b/src/lib/extend-ranges.ts @@ -3,55 +3,54 @@ import type { Coverage } from './parse-coverage' const AT_SIGN = 64 const LONGEST_ATRULE_NAME = '@-webkit-font-feature-values'.length -export function extend_ranges(coverage: Coverage[]): Coverage[] { - return coverage.map(({ text, ranges, url }) => { - // Adjust ranges to include @-rule name (only preludes included) - // Cannot reliably include closing } because it may not be the end of the range - let new_ranges = ranges.map((range, index) => { - let prev_range = ranges[index - 1] - // Add @atrule-name to the front of the range - // Heuristic: atrule names are no longer than LONGEST_ATRULE_NAME - for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) { - // Make sure to not overlap with the previous range - if (prev_range && prev_range.end > i) { - break - } +export function extend_ranges(coverage: Coverage): Coverage { + let { ranges, url, text } = coverage + // Adjust ranges to include @-rule name (only preludes included) + // Cannot reliably include closing } because it may not be the end of the range + let new_ranges = ranges.map((range, index) => { + let prev_range = ranges[index - 1] + // Add @atrule-name to the front of the range + // Heuristic: atrule names are no longer than LONGEST_ATRULE_NAME + for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) { + // Make sure to not overlap with the previous range + if (prev_range && prev_range.end > i) { + break + } - let char_position = i - if (text.charCodeAt(char_position) === AT_SIGN) { - // Move the start cursor back to the start of the @-sign - range.start = char_position - - // Look if the next character might be the opening { of the atrule's block - let next_offset = range.end - let next_char = text.charAt(next_offset) - // First eat all the whitespace that might be in-between - while (/\s/.test(next_char)) { - next_offset++ - next_char = text.charAt(next_offset) - } - - if (next_char === '{') { - range.end = range.end + 1 - } - break + let char_position = i + if (text.charCodeAt(char_position) === AT_SIGN) { + // Move the start cursor back to the start of the @-sign + range.start = char_position + + // Look if the next character might be the opening { of the atrule's block + let next_offset = range.end + let next_char = text.charAt(next_offset) + // First eat all the whitespace that might be in-between + while (/\s/.test(next_char)) { + next_offset++ + next_char = text.charAt(next_offset) } - } - // If the next non-whitespace character is }, add it to the current range - let offset = range.end - let next_char = text.charAt(offset) - while (/\s/.test(next_char)) { - offset++ - next_char = text.charAt(offset) - } - if (next_char === '}') { - range.end = offset + 1 + if (next_char === '{') { + range.end = range.end + 1 + } + break } - - return range - }) - - return { text, ranges: new_ranges, url } + } + + // If the next non-whitespace character is }, add it to the current range + let offset = range.end + let next_char = text.charAt(offset) + while (/\s/.test(next_char)) { + offset++ + next_char = text.charAt(offset) + } + if (next_char === '}') { + range.end = offset + 1 + } + + return range }) + + return { text, ranges: new_ranges, url } } diff --git a/src/lib/filter-entries.test.ts b/src/lib/filter-entries.test.ts index 8de45af..776da97 100644 --- a/src/lib/filter-entries.test.ts +++ b/src/lib/filter-entries.test.ts @@ -10,7 +10,7 @@ test('filters out JS files', () => { ranges: [{ start: 0, end: 25 }], }, ] satisfies Coverage[] - expect(filter_coverage(entries)).toEqual([]) + expect(entries.reduce((acc, entry) => filter_coverage(acc, entry), [])).toEqual([]) }) test('keeps files with CSS extension', () => { @@ -21,7 +21,7 @@ test('keeps files with CSS extension', () => { ranges: [{ start: 0, end: 13 }], }, ] satisfies Coverage[] - expect(filter_coverage(entries)).toEqual(entries) + expect(entries.reduce((acc, entry) => filter_coverage(acc, entry), [])).toEqual(entries) }) test('keeps extension-less URL with HTML text', () => { @@ -39,7 +39,7 @@ test('keeps extension-less URL with HTML text', () => { ranges: [{ start: 0, end: 13 }], // ranges are remapped }, ] satisfies Coverage[] - expect(filter_coverage(entries)).toEqual(expected) + expect(entries.reduce((acc, entry) => filter_coverage(acc, entry), [])).toEqual(expected) }) test('keeps extension-less URL with CSS text (running coverage in vite dev mode)', () => { @@ -50,7 +50,7 @@ test('keeps extension-less URL with CSS text (running coverage in vite dev mode) ranges: [{ start: 0, end: 13 }], }, ] satisfies Coverage[] - expect(filter_coverage(entries)).toEqual(entries) + expect(entries.reduce((acc, entry) => filter_coverage(acc, entry), [])).toEqual(entries) }) test('filters out extension-less JS', () => { @@ -61,5 +61,5 @@ test('filters out extension-less JS', () => { ranges: [{ start: 0, end: 29 }], }, ] satisfies Coverage[] - expect(filter_coverage(entries)).toEqual([]) + expect(entries.reduce((acc, entry) => filter_coverage(acc, entry), [])).toEqual([]) }) diff --git a/src/lib/filter-entries.ts b/src/lib/filter-entries.ts index 2b3d63c..2e658a5 100644 --- a/src/lib/filter-entries.ts +++ b/src/lib/filter-entries.ts @@ -2,7 +2,7 @@ import type { Coverage } from './parse-coverage.js' import { ext } from './ext.js' import { remap_html } from './remap-html.js' -function is_html(text: string): boolean { +export function is_html(text: string): boolean { return /<\/?(html|body|head|div|span|script|style)/i.test(text) } @@ -11,11 +11,11 @@ const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/ // Check for CSS properties (property: value pattern) const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m -function is_css_like(text: string): boolean { +export function is_css_like(text: string): boolean { return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text) } -function is_js_like(text: string): boolean { +export function is_js_like(text: string): boolean { try { // Only parses the input, does not execute it. // NEVER EXECUTE THIS UNTRUSTED CODE!!! @@ -26,37 +26,29 @@ function is_js_like(text: string): boolean { } } -export function filter_coverage(coverage: Coverage[]): Coverage[] { - let result = [] - - for (let entry of coverage) { - let extension = ext(entry.url).toLowerCase() - if (extension === 'js') continue - - // Always include CSS files - if (extension === 'css') { - result.push(entry) - continue - } - - if (is_html(entry.text)) { - let { css, ranges } = remap_html(entry.text, entry.ranges) - result.push({ - url: entry.url, - text: css, - ranges, - }) - continue - } - - if (is_css_like(entry.text) && !is_js_like(entry.text)) { - result.push({ - url: entry.url, - text: entry.text, - ranges: entry.ranges, - }) - } +export function filter_coverage(acc: Coverage[], entry: Coverage): Coverage[] { + let extension = ext(entry.url).toLowerCase() + if (extension === 'js') return acc + if (extension === 'css') { + acc.push(entry) + return acc } - - return result + if (is_html(entry.text)) { + let { css, ranges } = remap_html(entry.text, entry.ranges) + acc.push({ + url: entry.url, + text: css, + ranges, + }) + return acc + } + if (is_css_like(entry.text) && !is_js_like(entry.text)) { + acc.push({ + url: entry.url, + text: entry.text, + ranges: entry.ranges, + }) + return acc + } + return acc } diff --git a/src/lib/index.ts b/src/lib/index.ts index a6af056..adc2bbd 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -72,23 +72,12 @@ function calculate_stylesheet_coverage({ text, url, chunks }: PrettifiedCoverage } } -/** - * @description - * CSS Code Coverage calculation - * - * These are the steps performed to calculate coverage: - * 1. Filter eligible files / validate input - * 2. Prettify the CSS dicovered in each Coverage and update their ranges - * 3. De-duplicate Coverages: merge all ranges for CSS sources occurring multiple times - * 4. Calculate used/unused CSS bytes (fastest path, no inspection of the actual CSS needed) - * 5. Calculate line-coverage, byte-coverage per stylesheet - */ export function calculate_coverage(coverage: Coverage[]): CoverageResult { let total_files_found = coverage.length - let filtered_coverage: Coverage[] = filter_coverage(coverage) - let deduplicated: Coverage[] = deduplicate_entries(filtered_coverage) - let extended: Coverage[] = extend_ranges(deduplicated) + let filtered_coverage = coverage.reduce((acc, entry) => filter_coverage(acc, entry), []) + let deduplicated: Coverage[] = filtered_coverage.reduce((entries, entry) => deduplicate_entries(entries.concat(entry)), []) + let extended: Coverage[] = deduplicated.map((coverage) => extend_ranges(coverage)) let chunkified: ChunkedCoverage[] = extended.map((sheet) => chunkify(sheet)) let prettified: PrettifiedCoverage[] = chunkified.map((sheet) => prettify(sheet)) let coverage_per_stylesheet = prettified.map((stylesheet) => calculate_stylesheet_coverage(stylesheet)) diff --git a/src/lib/prettify.ts b/src/lib/prettify.ts index be12eb9..8130835 100644 --- a/src/lib/prettify.ts +++ b/src/lib/prettify.ts @@ -17,13 +17,14 @@ export function prettify(stylesheet: ChunkedCoverage): PrettifiedCoverage { let offset = 0 let pretty_chunks = stylesheet.chunks.map((chunk, index) => { - let css = format(stylesheet.text.slice(chunk.start_offset, chunk.end_offset - 1)).trim() + let chunk_css = stylesheet.text.substring(chunk.start_offset, chunk.end_offset - 1) + let css = format(chunk_css).trim() if (chunk.is_covered) { - let is_last = index === stylesheet.chunks.length - 1 + let is_last_chunk = index === stylesheet.chunks.length - 1 if (index === 0) { // mark the line between this chunk and the next on as covered - css = css + (is_last ? '' : '\n') + css = css + (is_last_chunk ? '' : '\n') } else if (index === stylesheet.chunks.length - 1) { // mark the newline after the previous uncovered block as covered css = '\n' + css From 4ded859cf2d339dce3e895c06966bb89aa88bcce Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 3 Nov 2025 23:11:25 +0100 Subject: [PATCH 2/2] knip --- src/lib/filter-entries.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/filter-entries.ts b/src/lib/filter-entries.ts index 2e658a5..92c899c 100644 --- a/src/lib/filter-entries.ts +++ b/src/lib/filter-entries.ts @@ -2,7 +2,7 @@ import type { Coverage } from './parse-coverage.js' import { ext } from './ext.js' import { remap_html } from './remap-html.js' -export function is_html(text: string): boolean { +function is_html(text: string): boolean { return /<\/?(html|body|head|div|span|script|style)/i.test(text) } @@ -11,11 +11,11 @@ const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/ // Check for CSS properties (property: value pattern) const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m -export function is_css_like(text: string): boolean { +function is_css_like(text: string): boolean { return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text) } -export function is_js_like(text: string): boolean { +function is_js_like(text: string): boolean { try { // Only parses the input, does not execute it. // NEVER EXECUTE THIS UNTRUSTED CODE!!!