diff --git a/package.json b/package.json index 057c6370b..3384fd4dd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "**/*.css" ], "main": "./dist/cjs/index.js", - "module": "index.js", + "module": "./dist/esm/index.js", "types": "index.d.ts", "exports": { ".": { diff --git a/scripts/verify-css-colors.mjs b/scripts/verify-css-colors.mjs new file mode 100644 index 000000000..e8054d849 --- /dev/null +++ b/scripts/verify-css-colors.mjs @@ -0,0 +1,63 @@ +/** + * Regenerates the browser-verified table in src/theme/cssColors.spec.ts. + * + * Whether a colour value is valid CSS is a question of fact, not of reading the + * grammar carefully — several rounds of review on this parser turned on cases where + * the grammar reads one way and browsers do another (`rgb(255 50% 0)` looks legal and + * is not; `\72ed` looks like `red` and is not). So the expectations are taken from a + * real engine and checked in, rather than argued about. + * + * Needs a browser, which CI does not have, so this is run by hand: + * + * npx playwright install chromium + * node scripts/verify-css-colors.mjs + * + * It prints the table; paste it over the CHROMIUM block in cssColors.spec.ts. The + * version is printed with it, because a verdict is only ever that engine's: this build + * has no oklch(), so it rejects values a newer one accepts. The assertion is written to + * survive that — the audit may always decline to resolve a value, it may never resolve + * one to different channels than the browser, and it may never resolve one the browser + * rejects. + */ +import { chromium } from '@playwright/test'; + +const VALUES = [ + '#ff0000', '#f00', '#f008', '#ff000080', '#027EB5', '#ABC', + '#12345', '#1234567', '#123456789', '#ggg', '#gggggg', '#12345g', + 'red', 'RED', 'Red', 'rebeccapurple', 'notacolor', 'tan', 'white', + 'r\\65 d', 're\\64', '\\red', '\\72 ed', '\\72ed', '\\110000', 'r\\65d', + 'rgb(255, 0, 0)', 'rgba(255, 0, 0, 0.5)', 'rgb(100%, 0%, 0%)', + 'rgba(0, 0, 0, 20%)', 'rgb(0, 50%, 0)', 'rgba(255, 50%, 0, 0.5)', + 'rgb(0, 0)', 'rgb(0, 0, 0, 0, 0)', 'rgb(0, 0, 0 / 0.5)', 'rgb(300, 0, 0)', + 'rgb(-10, 0, 0)', 'rgb(255 0 0)', 'rgb(255 0 0 / 0.5)', 'rgb(50% 50% 50%)', + 'rgb(255 50% 0)', 'rgb(0 0 0 0.5)', 'rgb(0 0 0 // 0.5)', 'rgb(0 0 0 /)', + 'rgb(2.55e2 0 0)', 'rgb(.0 0 0)', 'rgb(+255 0 0)', 'rgb(. 0 0)', + 'rgb(1..2 0 0)', 'rgb(1e 0 0)', 'rgb(0 0 0 / 50%)', 'rgb(var(--c), 0, 0)', + 'hsl(0 100% 50%)', 'hsl(0, 100%, 50%)', 'oklch(0.7 0.1 200)', + 'color(display-p3 1 0 0)', 'hwb(0 0% 0%)', 'lab(50% 40 30)', +]; + +const SENTINEL = 'rgb(1, 2, 3)'; +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.setContent( + `' + + VALUES.map((_, i) => `

x

`).join('') +); + +const rows = []; +for (let i = 0; i < VALUES.length; i++) { + const computed = await page.$eval('#c' + i, (el) => getComputedStyle(el).color); + rows.push([VALUES[i], computed === SENTINEL ? 'REJECTED' : computed]); +} +const version = browser.version(); +await browser.close(); + +console.log(` // generated by scripts/verify-css-colors.mjs against Chromium ${version}`); + +const escape = (text) => text.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +const width = Math.max(...rows.map(([v]) => escape(v).length)) + 3; +console.log(rows + .map(([v, c]) => ` [${(`'${escape(v)}',`).padEnd(width)}'${c}'],`) + .join('\n')); diff --git a/src/theme/cssColors.spec.ts b/src/theme/cssColors.spec.ts new file mode 100644 index 000000000..32c385387 --- /dev/null +++ b/src/theme/cssColors.spec.ts @@ -0,0 +1,778 @@ +import { + Rgba, + colorKey, + declarations, + describeColor, + findColors, + opaqueKey, + stripNoise, + stylesheetColors, + takesColor, +} from './cssColors'; + +const literals = (css: string) => stylesheetColors(css).map((found) => found.literal); +const values = (css: string) => declarations(css).map((declaration) => declaration.value); + +describe('stripNoise', () => { + it('removes block comments', () => { + expect(stripNoise('a { /* #ff0000 */ color: red; }')).not.toContain('#ff0000'); + }); + + it('removes string contents so content: "tan" is not a colour', () => { + expect(stripNoise('a { content: "tan"; }')).not.toContain('tan'); + }); + + it('removes url() payloads', () => { + const blanked = stripNoise('a { background: url(data:image/svg+xml;base64,Zm9v) no-repeat; }'); + expect(blanked).not.toContain('base64'); + expect(blanked).toContain('no-repeat'); + }); + + it('only blanks url(), not a function whose name merely ends in url', () => { + // `myurl(...)` is an unknown container to be descended into, not noise + expect(stripNoise('a { --x: myurl(#fff); }')).toContain('#fff'); + }); + + it('keeps the url() parentheses, which are structure rather than noise', () => { + // declarations balances parens to know a `;` inside url() is not a separator + expect(stripNoise('a { background: url(x;y); }')).toContain('url('); + expect(stripNoise('a { background: url(x;y); }')).toContain(')'); + }); + + it('does not end a url() at a parenthesis inside its quoted payload', () => { + // `url("icon).svg")` closes at the last paren. Stopping at the first one leaves the + // trailing quote behind, and that "unterminated string" blanks the rest of the rule. + const blanked = stripNoise('a { background: url("icon).svg"); color: red; }'); + expect(blanked).not.toContain('icon'); + expect(blanked).toContain('color: red;'); + }); + + it('does not end a url() at an escaped parenthesis', () => { + expect(stripNoise('a { background: url(icon\\).svg); color: red; }')) + .toContain('color: red;'); + }); + + it('treats an escaped quote in a selector as ordinary text', () => { + // `.foo\"bar` is a valid class name. Reading its quote as a string opener blanks + // everything after it, and the declaration disappears from the audit. + expect(stripNoise('.foo\\"bar { color: red; }')).toContain('color: red;'); + }); + + it('handles an escaped quote inside a string', () => { + expect(stripNoise('a { content: "a\\"b"; }')).not.toContain('b"'); + }); + + it('tolerates an unterminated comment', () => { + expect(stripNoise('a { color: red; /* oops')).toContain('color: red;'); + }); + + it.each([ + ['a comment', 'a { /* note */ color: red; }'], + ['a string', 'a { content: "tan"; }'], + ['an unterminated string', 'a { content: "tan }'], + ['a url()', 'a { background: url(data:image/svg+xml;base64,Zm9v); }'], + ['an unterminated url()', 'a { background: url(oops }'], + ['a url() with a paren in its payload', 'a { background: url("icon).svg"); }'], + ['a url() with an unterminated quoted payload', 'a { background: url("oops }'], + ['a url() ending in a trailing escape', 'a { background: url(oops\\'], + ['an escaped quote in a selector', '.foo\\"bar { color: red; }'], + ['a stylesheet ending in an escape', 'a { color: red; } \\'], + ['an unterminated comment', 'a { color: red; /* oops'], + ])('blanks %s without changing the length', (_case, css) => { + // declarations addresses two differently-blanked copies with one index, so this + // is load-bearing rather than cosmetic: a length change silently misaligns context. + expect(stripNoise(css)).toHaveLength(css.length); + }); +}); + +describe('declarations', () => { + it('reads declarations at the top level of a rule', () => { + expect(values('a { color: red; background: blue; }')).toEqual(['red', 'blue']); + }); + + it('reads declarations nested in @media', () => { + expect(values('@media (max-width: 50em) { a { color: red; } }')).toEqual(['red']); + }); + + it('does not mistake a pseudo-class selector for a declaration', () => { + expect(values('a:hover { color: red; }')).toEqual(['red']); + }); + + it('does not mistake @keyframes percentages for declarations', () => { + expect(values('@keyframes f { 0% { opacity: 0; } 100% { opacity: 1; } }')) + .toEqual(['0', '1']); + }); + + it('ignores at-rules outside a block, such as @import', () => { + expect(values('@import "./theme.css";')).toEqual([]); + }); + + it('reads a declaration with no trailing semicolon', () => { + expect(values('a { color: red }')).toEqual(['red']); + }); + + it('does not split on a semicolon inside parentheses', () => { + expect(values('a { background: url(x;y); color: red; }')).toContain('red'); + }); + + it('keeps the declarations after a url() whose payload contains a parenthesis', () => { + const parsed = values('a { background: url("icon).svg"); color: red; }'); + + expect(parsed).toHaveLength(2); + expect(parsed[1]).toEqual('red'); + }); + + it('reads several url() payloads in one value', () => { + const parsed = declarations('a { background: url("a).svg") no-repeat, url(b); color: red; }'); + + expect(parsed).toHaveLength(2); + expect(parsed[1]).toEqual({ context: 'a', property: 'color', value: 'red' }); + }); + + it('keeps a brace block as a custom property value rather than a nested rule', () => { + // `--x: { red }` is a valid declaration whose value is a block of component + // values. Pushing the block as selector context loses the value entirely. + expect(declarations(':root { --x: { red }; }')) + .toEqual([{ context: ':root', property: '--x', value: '{ red }' }]); + }); + + it('still reads a nested rule as a rule, not as a value', () => { + // the brace-block rule must not swallow real nesting: this is two contexts + expect(declarations('a { color: red; b { color: blue; } }').map((d) => d.context)) + .toEqual(['a', 'a b']); + }); + + it('keeps a custom property declaration', () => { + expect(values(':root { --ox-color-x: #fff; }')).toEqual(['#fff']); + }); + + it('lower-cases the property name', () => { + expect(declarations('a { COLOR: red; }')[0].property).toEqual('color'); + }); + + it('keeps the case of a custom property name, which CSS is case-sensitive about', () => { + // `--Brand` and `--brand` are two different custom properties, so lower-casing them + // would merge two distinct declarations in the audit metadata. + expect(declarations(':root { --Brand: #fff; --brand: #000; }') + .map(({ property }) => property)).toEqual(['--Brand', '--brand']); + }); + + it('records the selector as context', () => { + expect(declarations('a:hover .thing { color: red; }')[0].context) + .toEqual('a:hover .thing'); + }); + + it('collapses whitespace in the context', () => { + expect(declarations('a,\n b {\n color: red;\n}')[0].context).toEqual('a, b'); + }); + + it('does not collapse whitespace inside a selector string', () => { + // the whole point of keeping string contents is that two rules differing only + // inside a selector string stay distinguishable — collapsing runs of spaces there + // merges them again. + const parsed = declarations( + '[data-label="a b"] { color: #fff; } [data-label="a b"] { color: #fff; }' + ); + + expect(parsed.map(({ context }) => context)) + .toEqual(['[data-label="a b"]', '[data-label="a b"]']); + }); + + it('nests the at-rule prelude and the selector in the context', () => { + expect(declarations('@media (max-width: 50em) { a { color: red; } }')[0].context) + .toEqual('@media (max-width: 50em) a'); + }); + + it('pops the context again after a nested block closes', () => { + const parsed = declarations('@media (max-width: 50em) { a { color: red; } } b { color: blue; }'); + expect(parsed.map(({ context }) => context)) + .toEqual(['@media (max-width: 50em) a', 'b']); + }); + + it('keeps string contents in the context, so attribute selectors stay distinct', () => { + // a consumer may identify an occurrence by its context, so two rules that differ + // only inside a selector string must not reduce to the same one. + const parsed = declarations( + '.x[data-loading="true"] { color: #fff; } .x[data-loading="false"] { color: #fff; }' + ); + + expect(parsed.map(({ context }) => context)) + .toEqual(['.x[data-loading="true"]', '.x[data-loading="false"]']); + }); + + it('still blanks strings in the value, where they are not colours', () => { + // the other half of the same change: context keeps strings, values must not, or + // `content: "#fff"` starts reading as a colour. + expect(declarations('a { content: "#fff"; }')).toEqual([]); + }); + + it.each([ + ['a quote', '.foo\\"bar'], + ['a brace', '.foo\\{bar'], + ['a semicolon', '.foo\\;bar'], + ])('does not read %s escaped in a selector as structure', (_case, selector) => { + // each of these is one class name. Read as structure they corrupt the context + // stack — the brace opens a block that never closes, the semicolon truncates the + // selector — and the quote blanks the rest of the stylesheet outright. + expect(declarations(`${selector} { color: red; }`)) + .toEqual([{ context: selector, property: 'color', value: 'red' }]); + }); + + it('does not let a brace inside a selector string open a block', () => { + expect(declarations('.x[data-glyph="{"] { color: red; }')) + .toEqual([{ context: '.x[data-glyph="{"]', property: 'color', value: 'red' }]); + }); +}); + +describe('takesColor', () => { + it.each(['color', 'background-color', 'border-top-color', '-webkit-text-fill-color'])( + 'accepts %s, which names a colour', (property) => { + expect(takesColor(property)).toBe(true); + } + ); + + it.each([ + 'background', 'border', 'border-left', 'box-shadow', 'outline', 'fill', 'scrollbar', + ])('accepts the %s shorthand', (property) => { + expect(takesColor(property)).toBe(true); + }); + + it('accepts a custom property, which has no grammar to go on', () => { + expect(takesColor('--tabs-border-color')).toBe(true); + }); + + it.each(['animation-name', 'font-family', 'transition-property', 'grid-area'])( + 'rejects %s, where an identifier is not a colour', (property) => { + expect(takesColor(property)).toBe(false); + } + ); + + it.each(['border-radius', 'border-width', 'border-collapse'])( + 'rejects %s, which is border-shaped but cannot hold a colour', (property) => { + // matching the whole border family by prefix would let a named colour through here + expect(takesColor(property)).toBe(false); + } + ); + + it.each(['list-style', 'color-scheme'])( + 'rejects %s, whose bare identifier names something the author defined', (property) => { + // `@counter-style red` and a `red` colour scheme are both legal, and neither is + // a colour — so neither can be reported as one. + expect(takesColor(property)).toBe(false); + } + ); + + it.each([ + 'print-color-adjust', '-webkit-print-color-adjust', 'forced-color-adjust', + 'color-interpolation-filters', + ])('rejects %s, which is colour-named but holds no colour', (property) => { + expect(takesColor(property)).toBe(false); + }); + + it('sees through a vendor prefix', () => { + expect(takesColor('-webkit-box-shadow')).toBe(true); + }); +}); + +describe('findColors', () => { + it('finds a hex literal', () => { + expect(literals('a { color: #ff0000; }')).toEqual(['#ff0000']); + }); + + it('finds a bare named colour in a shorthand', () => { + expect(literals('a { border: 0.1rem solid red; }')).toEqual(['red']); + }); + + it('finds colours in gradient stops', () => { + expect(literals('a { background: linear-gradient(to top, #fff 0%, #000 100%); }')) + .toEqual(['#fff', '#000']); + }); + + it('descends into var() fallbacks rather than treating var() as a literal', () => { + expect(literals('a { color: var(--x, #fff); }')).toEqual(['#fff']); + }); + + it('descends into color-mix() over tokens and finds nothing', () => { + expect(literals('a { color: color-mix(in srgb, var(--a), var(--b)); }')).toEqual([]); + }); + + it('finds rgba()', () => { + expect(literals('a { box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.2); }')) + .toEqual(['rgba(0, 0, 0, 0.2)']); + }); + + it.each([ + ['hsl()', 'a { color: hsl(0deg 100% 50%); }'], + ['oklch()', 'a { color: oklch(0.7 0.1 200); }'], + ['color()', 'a { color: color(display-p3 1 0 0); }'], + ['device-cmyk()', 'a { color: device-cmyk(0 0.5 1 0); }'], + // rgba() may legally hold var() channels, but then there is no telling what colour + // it is. Reporting it beats skipping it, which would let an unchecked colour past. + ['rgba() over a var() channel list', 'a { color: rgba(var(--channels), 0.2); }'], + ])('finds %s, which resolves to null so it cannot pass silently', (_case, css) => { + const found = stylesheetColors(css); + expect(found).toHaveLength(1); + expect(found[0].rgba).toBeNull(); + }); + + it('does not treat a class selector named .red as a colour', () => { + expect(literals('.red { opacity: 1; }')).toEqual([]); + }); + + it('does not treat content: "tan" as a colour', () => { + expect(literals('a { content: "tan"; }')).toEqual([]); + }); + + it('does not treat a colour inside a comment as a colour', () => { + expect(literals('a { /* was #ff0000 */ color: var(--x); }')).toEqual([]); + }); + + it('does not treat transparent or currentcolor as comparable colours', () => { + expect(literals('a { color: currentcolor; background: transparent; }')).toEqual([]); + }); + + it('does not treat a non-colour keyword as a colour', () => { + expect(literals('a { transition: all 0.2s linear; }')).toEqual([]); + }); + + it('finds several colours in one declaration', () => { + expect(literals('a { box-shadow: 0 0 0 red, 0 0 0 #00f; }')).toEqual(['red', '#00f']); + }); + + it('tolerates an unbalanced function call', () => { + expect(() => literals('a { color: rgb(0, 0, 0; }')).not.toThrow(); + }); + + it.each([ + ['an animation name', 'a { animation-name: red; }'], + ['a font family', 'a { font-family: black; }'], + ['a transitioned property', 'a { transition-property: tan; }'], + ['a grid area', 'a { grid-area: navy; }'], + ['a counter style', 'a { list-style: red; }'], + ['a colour scheme', 'a { color-scheme: red; }'], + // the property gate has to survive the descent into a function, not just the + // top level of the value — findColors passes `named` down to itself. + ['a var() fallback under one', 'a { animation-name: var(--enter, red); }'], + ])('does not read %s as a named colour', (_case, css) => { + expect(literals(css)).toEqual([]); + }); + + it.each([ + ['a colour property', 'a { color: red; }'], + ['a shorthand', 'a { border: 0.1rem solid red; }'], + ['a custom property', 'a { --x: red; }'], + ['a box-shadow', 'a { box-shadow: 0 0 0.2rem red; }'], + ['a vendor-prefixed property', 'a { -webkit-text-fill-color: red; }'], + // the other side of the descent: gating named colours on the property must not + // stop finding them inside a function the walk descends into. + ['a gradient stop', 'a { background: linear-gradient(to top, red, transparent); }'], + ])('still reads a named colour in %s', (_case, css) => { + expect(literals(css)).toEqual(['red']); + }); + + it.each([ + ['a gradient', 'a { list-style-image: linear-gradient(red, blue); }'], + ['a repeating gradient', 'a { list-style-image: repeating-conic-gradient(red, blue); }'], + ['a vendor-prefixed gradient', 'a { list-style-image: -webkit-linear-gradient(red, blue); }'], + ['color-mix()', 'a { list-style-image: color-mix(in srgb, red, blue); }'], + ])('reads named colours in %s under a property that cannot hold one', (_case, css) => { + // `list-style-image` takes an image, but a gradient's stops are colours wherever + // the gradient is written, so the property gate must not reach inside one. + expect(literals(css)).toEqual(['red', 'blue']); + }); + + it('reads the fallback colour in image(), whose first argument is a url', () => { + // `image()` takes an image and then a bare `` to fall back to, so the stop + // is a colour however the property is spelled. The url payload is blanked, so the + // `#` of a fragment in it cannot be read as a hex literal. + expect(literals('a { list-style-image: image(url(marker.svg#a), red); }')) + .toEqual(['red']); + }); + + it('does not open the named-colour gate inside image-set(), which holds no colour', () => { + // the sibling function takes images and resolutions only, so an identifier there is + // not a colour -- it is listed as deliberately absent from COLOR_CONTAINERS. + expect(literals('a { list-style-image: image-set(red 1x); }')).toEqual([]); + // a gradient inside one still opens its own gate, so nothing is lost by the absence + expect(literals('a { list-style-image: image-set(linear-gradient(red, blue) 1x); }')) + .toEqual(['red', 'blue']); + }); + + it('keeps the property gate inside var(), whose fallback is not known to be a colour', () => { + // the other half: `var()` is whatever the property makes of it, so an identifier in + // a fallback is only a colour when the property says so. + expect(literals('a { animation-name: var(--enter, red); }')).toEqual([]); + expect(literals('a { color: var(--enter, red); }')).toEqual(['red']); + }); + + it('still finds a gradient in the list-style shorthand', () => { + // dropping `list-style` from the shorthands must not lose its image component: + // the gradient opens the gate for its own stops whatever property it sits in. + expect(literals('a { list-style: square linear-gradient(red, blue); }')) + .toEqual(['red', 'blue']); + }); + + it('still reads hex and rgb() in a property that cannot take a named colour', () => { + // only the bare-identifier case is property-sensitive: `#fff` and `rgb(...)` are + // colours wherever they are written, so they stay in scope everywhere. + expect(literals('a { animation-name: #fff; transition-property: rgb(0, 0, 0); }')) + .toEqual(['#fff', 'rgb(0, 0, 0)']); + }); + + it('has no default for the named-colour gate, so a caller cannot forget it', () => { + // defaulting it to true would quietly restore reading `animation-name: red` as a + // colour for any call site that omitted the argument. + expect(findColors('red', true)).toHaveLength(1); + expect(findColors('red', false)).toEqual([]); + }); + + it.each([ + ['a url fragment', 'url(#fff)'], + ['a quoted string', '"red"'], + ['a string in a shorthand', '0 0 0 "red"'], + ['a comment', '/* red */ 0'], + ['a data: URI', 'url(data:image/svg+xml;utf8,)'], + ])('blanks %s in a raw value handed straight to findColors', (_case, value) => { + // findColors takes a value as written, not one a caller has already cleaned up: + // `stylesheetColors` gets that for free from `declarations` and a direct caller + // should not have to know it is a precondition. + expect(findColors(value, true)).toEqual([]); + }); + + it.each([ + ['a hex literal', '#fff', '#fff'], + ['a named colour beside a string', '"x" red', 'red'], + ['a colour after a url', 'url(a.svg) red', 'red'], + ])('still finds %s in a raw value', (_case, value, literal) => { + // the other direction: blanking the noise must not blank the colours with it + expect(findColors(value, true).map((found) => found.literal)).toEqual([literal]); + }); + + it.each([ + ['a hex escape with its terminating space', 'a { color: r\\65 d; }', 'r\\65 d'], + ['a hex escape at the end of the identifier', 'a { color: re\\64; }', 're\\64'], + ['an escaped ordinary character', 'a { color: \\red; }', '\\red'], + ['a hex escape spelling the first letter', 'a { color: \\72 ed; }', '\\72 ed'], + ])('decodes %s so the named colour is not evaded', (_case, css, literal) => { + // CSS tokenizes all three of these as the identifier `red`, so an audit that + // reads them as separate words is trivially bypassed. + const found = stylesheetColors(css); + expect(found).toHaveLength(1); + // the literal stays the original source span, since that is what a consumer + // has to find and rewrite in the file + expect(found[0].literal).toEqual(literal); + expect(found[0].rgba).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('does not decode an escape into a colour where the property forbids one', () => { + expect(literals('a { animation-name: r\\65 d; }')).toEqual([]); + }); + + it('consumes up to six hex digits, so \\72ed is one character and not red', () => { + // `e` and `d` are hex digits, so this escape is U+72ED and the declaration is not + // a colour at all — Chromium rejects it. Stopping at two digits would invent a + // finding out of valid CSS. + expect(literals('a { color: \\72ed; }')).toEqual([]); + }); + + it('does not throw on an escape outside the Unicode range', () => { + expect(() => literals('a { color: \\110000 ; }')).not.toThrow(); + }); + + it.each(['constructor', '__proto__'])( + 'does not report or crash on %s, which is inherited rather than a colour', (name) => { + // a crash in the audit takes down the suite of whichever consumer is running it, + // so this is worse than the wrong answer it also gave + expect(() => stylesheetColors(`a { color: ${name}; }`)).not.toThrow(); + expect(literals(`a { color: ${name}; }`)).toEqual([]); + expect(literals(`:root { --x: ${name}; }`)).toEqual([]); + } + ); + + it('records the declaration each colour was written in', () => { + expect(stylesheetColors('@media (max-width: 50em) { .a:hover { color: #fff; } }')) + .toEqual([{ + context: '@media (max-width: 50em) .a:hover', + literal: '#fff', + property: 'color', + rgba: { r: 255, g: 255, b: 255, a: 1 }, + }]); + }); +}); + +describe('describeColor', () => { + it('expands 3-digit hex', () => { + expect(describeColor('#fff')).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + }); + + it('reads 8-digit hex alpha', () => { + expect(describeColor('#00000033')?.a).toBeCloseTo(0.2, 1); + }); + + it('reads 4-digit hex', () => { + expect(describeColor('#0000')).toEqual({ r: 0, g: 0, b: 0, a: 0 }); + }); + + it('is case insensitive', () => { + expect(describeColor('#027EB5')).toEqual(describeColor('#027eb5')); + }); + + it('resolves a named colour', () => { + expect(describeColor('white')).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + }); + + it('reads comma-separated rgb()', () => { + expect(describeColor('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('reads space-separated rgb() with a slash alpha', () => { + expect(describeColor('rgb(255 0 0 / 0.5)')).toEqual({ r: 255, g: 0, b: 0, a: 0.5 }); + }); + + it('reads percentage channels', () => { + expect(describeColor('rgb(100%, 0%, 0%)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('reads a percentage alpha', () => { + expect(describeColor('rgba(0, 0, 0, 20%)')?.a).toBeCloseTo(0.2); + }); + + it('returns null for hsl()', () => { + expect(describeColor('hsl(0, 100%, 50%)')).toBeNull(); + }); + + it('returns null for a non-numeric channel', () => { + expect(describeColor('rgb(var(--x), 0, 0)')).toBeNull(); + }); + + it.each([ + // modern syntax puts the alpha behind a single slash; without it this is four + // channels, which is not a grammar rgb() has + 'rgb(0 0 0 0.5)', + 'rgb(0 0 0 // 0.5)', + 'rgb(0 0 0 /)', + // and the legacy comma syntax has no slash at all + 'rgb(0, 0, 0 / 0.5)', + ])('returns null for the malformed rgb() grammar %s', (literal) => { + expect(describeColor(literal)).toBeNull(); + }); + + it.each([ + 'rgb(0, 50%, 0)', 'rgba(255, 50%, 0, 0.5)', + // not just the comma syntax: rgb() splits into an all-number and an + // all-percentage production in both spellings, so neither permits mixing. + // Chromium rejects all three of these. + 'rgb(255 50% 0)', + ])('returns null for %s, since rgb() cannot mix channel units', (literal) => { + expect(describeColor(literal)).toBeNull(); + }); + + it('reads an all-percentage legacy triple, which is consistent', () => { + expect(describeColor('rgb(100%, 50%, 0%)')).toEqual({ r: 255, g: 128, b: 0, a: 1 }); + }); + + it('returns null for the wrong number of channels', () => { + expect(describeColor('rgb(0, 0)')).toBeNull(); + }); + + it('returns null for an unknown identifier', () => { + expect(describeColor('notacolor')).toBeNull(); + }); + + it.each(['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty'])( + 'returns null for %s rather than reading a key off Object.prototype', (name) => { + // `constructor` and `__proto__` are the inherited keys that survive being + // lower-cased. They used to look up to a function and an object, both truthy, + // which `fromHex` then crashed on. + expect(describeColor(name)).toBeNull(); + } + ); + + it.each(['#12345', '#1234567', '#123456789'])( + 'returns null for the malformed hex length %s', (literal) => { + expect(describeColor(literal)).toBeNull(); + } + ); + + it.each(['#ggg', '#gggggg', '#12345g'])( + 'returns null for %s rather than a set of NaN channels', (literal) => { + // the expanded length is right, so checking only the length would hand back + // {r: NaN, g: NaN, b: NaN} and read as a resolved colour — which is how a + // malformed palette value used to pass the consumer's resolvability guard. + expect(describeColor(literal)).toBeNull(); + } + ); + + it.each([ + 'rgb(., 0, 0)', 'rgb(1..2, 0, 0)', 'rgb(1.2.3, 0, 0)', 'rgb(0, 0, 0, .)', + 'rgba(0, 0, 0, 1..2)', 'rgb(.%, 0, 0)', 'rgb(1e, 0, 0)', + ])('returns null for the malformed number in %s', (literal) => { + // `[\\d.]+` also matches `.` and `1..2`; parseFloat turns those into NaN and a + // truncated 1, either of which would be handed back as a resolved channel. + expect(describeColor(literal)).toBeNull(); + }); + + it.each([ + ['no integer part', 'rgb(.0, 0, 0)'], + ['an explicit plus sign', 'rgb(+255, 0, 0)'], + ['exponent notation', 'rgb(2.55e2, 0, 0)'], + ])('still reads a channel written with %s', (_case, literal) => { + expect(describeColor(literal)?.r).toEqual(literal.includes('.0') ? 0 : 255); + }); + + it('rounds a percentage channel the same way as its integer spelling', () => { + // 50% of 255 is 127.5, which rounds to 128. Scaling by the decimal 2.55 gives + // 127.49999999999999 and rounds to 127, so the two spellings would disagree. + expect(describeColor('rgb(50%, 50%, 50%)')).toEqual({ r: 128, g: 128, b: 128, a: 1 }); + expect(describeColor('rgb(50%, 50%, 50%)')).toEqual(describeColor('rgb(128, 128, 128)')); + }); +}); + +describe('colour keys', () => { + const rgbaOf = (literal: string) => { + const rgba = describeColor(literal); + if (rgba === null) { throw new Error(`${literal} did not resolve to channels`); } + return rgba; + }; + const key = (literal: string) => colorKey(rgbaOf(literal)); + + it('is the hex form, which is what an allowlist entry has to be recognisable as', () => { + expect(key('#ccc')).toEqual('#cccccc'); + }); + + it('treats an opaque colour as equal however it is written', () => { + expect(key('#fff')).toEqual(key('white')); + expect(key('rgb(50%, 50%, 50%)')).toEqual(key('#808080')); + }); + + it('distinguishes a translucent colour from its opaque form', () => { + expect(key('rgba(0, 0, 0, 0.2)')).not.toEqual(key('#000')); + }); + + it('keeps alpha decimal rather than rounding it onto a hex pair', () => { + // two alphas a hex pair cannot tell apart must not collapse onto one key + expect(key('rgba(0, 0, 0, 0.2)')).toEqual('#000000/0.2'); + expect(key('rgba(0, 0, 0, 0.201)')).not.toEqual(key('rgba(0, 0, 0, 0.2)')); + }); + + it('recognises a translucent colour by its opaque channels', () => { + expect(opaqueKey(rgbaOf('rgba(0, 0, 0, 0.2)'))) + .toEqual(opaqueKey(rgbaOf('#000'))); + }); +}); + +/** + * What a real engine makes of each value, generated by scripts/verify-css-colors.mjs. + * + * Whether a colour value is valid CSS turned out to be a question of fact rather than + * of reading the grammar carefully. Two rounds of review here turned on cases where the + * grammar reads one way and browsers go the other: `rgb(255 50% 0)` looks legal and is + * not, because rgb() splits into an all-number and an all-percentage production rather + * than accepting three of either; `\72ed` looks like `red` and is not, because hex + * escapes consume up to six digits and `e` and `d` are hex digits. So these are taken + * from the engine instead of argued about. + * + * The verdict is only that build's, so the assertion is one-directional where it has to + * be: this engine has no oklch(), and a newer one would accept what it rejects. The + * audit may always decline to resolve a value, since `hsl()` and the rest are reported + * as unresolvable by design. What it may never do is resolve one to different channels + * than the browser, or resolve one the browser throws away. + */ +const CHROMIUM: Array<[string, string]> = [ + // generated by scripts/verify-css-colors.mjs against Chromium 105.0.5195.19 + ['#ff0000', 'rgb(255, 0, 0)'], + ['#f00', 'rgb(255, 0, 0)'], + ['#f008', 'rgba(255, 0, 0, 0.533)'], + ['#ff000080', 'rgba(255, 0, 0, 0.5)'], + ['#027EB5', 'rgb(2, 126, 181)'], + ['#ABC', 'rgb(170, 187, 204)'], + ['#12345', 'REJECTED'], + ['#1234567', 'REJECTED'], + ['#123456789', 'REJECTED'], + ['#ggg', 'REJECTED'], + ['#gggggg', 'REJECTED'], + ['#12345g', 'REJECTED'], + ['red', 'rgb(255, 0, 0)'], + ['RED', 'rgb(255, 0, 0)'], + ['Red', 'rgb(255, 0, 0)'], + ['rebeccapurple', 'rgb(102, 51, 153)'], + ['notacolor', 'REJECTED'], + ['tan', 'rgb(210, 180, 140)'], + ['white', 'rgb(255, 255, 255)'], + ['r\\65 d', 'rgb(255, 0, 0)'], + ['re\\64', 'rgb(255, 0, 0)'], + ['\\red', 'rgb(255, 0, 0)'], + ['\\72 ed', 'rgb(255, 0, 0)'], + ['\\72ed', 'REJECTED'], + ['\\110000', 'REJECTED'], + ['r\\65d', 'REJECTED'], + ['rgb(255, 0, 0)', 'rgb(255, 0, 0)'], + ['rgba(255, 0, 0, 0.5)', 'rgba(255, 0, 0, 0.5)'], + ['rgb(100%, 0%, 0%)', 'rgb(255, 0, 0)'], + ['rgba(0, 0, 0, 20%)', 'rgba(0, 0, 0, 0.2)'], + ['rgb(0, 50%, 0)', 'REJECTED'], + ['rgba(255, 50%, 0, 0.5)', 'REJECTED'], + ['rgb(0, 0)', 'REJECTED'], + ['rgb(0, 0, 0, 0, 0)', 'REJECTED'], + ['rgb(0, 0, 0 / 0.5)', 'REJECTED'], + ['rgb(300, 0, 0)', 'rgb(255, 0, 0)'], + ['rgb(-10, 0, 0)', 'rgb(0, 0, 0)'], + ['rgb(255 0 0)', 'rgb(255, 0, 0)'], + ['rgb(255 0 0 / 0.5)', 'rgba(255, 0, 0, 0.5)'], + ['rgb(50% 50% 50%)', 'rgb(128, 128, 128)'], + ['rgb(255 50% 0)', 'REJECTED'], + ['rgb(0 0 0 0.5)', 'REJECTED'], + ['rgb(0 0 0 // 0.5)', 'REJECTED'], + ['rgb(0 0 0 /)', 'REJECTED'], + ['rgb(2.55e2 0 0)', 'rgb(255, 0, 0)'], + ['rgb(.0 0 0)', 'rgb(0, 0, 0)'], + ['rgb(+255 0 0)', 'rgb(255, 0, 0)'], + ['rgb(. 0 0)', 'REJECTED'], + ['rgb(1..2 0 0)', 'REJECTED'], + ['rgb(1e 0 0)', 'REJECTED'], + ['rgb(0 0 0 / 50%)', 'rgba(0, 0, 0, 0.5)'], + ['rgb(var(--c), 0, 0)', 'REJECTED'], + ['hsl(0 100% 50%)', 'rgb(255, 0, 0)'], + ['hsl(0, 100%, 50%)', 'rgb(255, 0, 0)'], + ['oklch(0.7 0.1 200)', 'REJECTED'], + ['color(display-p3 1 0 0)','REJECTED'], + ['hwb(0 0% 0%)', 'rgb(255, 0, 0)'], + ['lab(50% 40 30)', 'REJECTED'], +]; + +describe('agreement with a browser', () => { + /** + * Alpha is compared as eighths of a bit rather than as a decimal, because that is all + * the precision a browser keeps: Chromium serialises `#ff000080` as alpha `0.5`, not + * as 128/255 = `0.502`. That is a narrower comparison than `colorKey` makes, which + * holds alpha decimal on purpose so that two allowlist entries cannot collide — a + * different question from whether we agree with the browser about the colour. + */ + const comparable = ({ r, g, b, a }: Rgba) => + `rgb(${r}, ${g}, ${b}) at alpha ${Math.round(a * 255)}/255`; + + const asChromiumSees = (serialised: string) => { + const args = /^rgba?\(([^)]*)\)$/.exec(serialised); + if (args === null) { throw new Error(`cannot read ${serialised}`); } + const [r, g, b, a] = args[1].split(',').map((part) => parseFloat(part)); + return comparable({ r, g, b, a: a === undefined ? 1 : a }); + }; + + it.each(CHROMIUM)('resolves %s as Chromium does', (value, verdict) => { + const resolved = stylesheetColors(`a { color: ${value}; }`) + .map(({ rgba }) => rgba) + .filter((rgba): rgba is Rgba => rgba !== null); + + if (verdict === 'REJECTED') { + // resolving one of these is how a malformed theme value passes the consumer's + // "every colour token resolves" guard while generating CSS the browser drops + expect(resolved.map(comparable)).toEqual([]); + return; + } + + // declining is allowed, being wrong is not + if (resolved.length === 0) { return; } + + expect(comparable(resolved[0])).toEqual(asChromiumSees(verdict)); + }); +}); diff --git a/src/theme/cssColors.ts b/src/theme/cssColors.ts new file mode 100644 index 000000000..976dc69dd --- /dev/null +++ b/src/theme/cssColors.ts @@ -0,0 +1,780 @@ +/** + * Colour auditing for plain-CSS stylesheets: text in, structured colours out. + * + * This parses declarations rather than grepping for `#hex`, because a grep misses + * `rgba()`, `hsl()`, named colours in shorthands and colours in gradient stops — all of + * which can silently duplicate or diverge from a theme value. + * + * Two rules keep it publishable: + * + * - **No `fs`, no `path`, no node built-ins.** It resolves to the `browser` export + * condition via the wildcard subpath, in a library that is otherwise browser-only, so + * a node import here is something a consumer's bundler could try to follow. The file + * walk belongs to whoever owns the file tree anyway. + * - **No policy.** What counts as a theme value, which off-palette colours are + * tolerated and how a violation is worded all differ between consumers and all stay + * with them. This file only answers "what colours does this CSS contain, and what are + * they" — see src/theme/tokens.spec.ts for the ui-components layer on top. + */ + +export interface Rgba { + r: number; + g: number; + b: number; + /** 0–1; 1 for an opaque colour. */ + a: number; +} + +export interface FoundColor { + /** The literal exactly as written, e.g. `rgba(0, 0, 0, 0.2)`. */ + literal: string; + /** Resolved channels, or null when this syntax cannot be resolved statically. */ + rgba: Rgba | null; +} + +/** A declaration, with enough of its surroundings to identify it again. */ +export interface Declaration { + /** + * The selectors and at-rule preludes the declaration sits inside, outermost first, + * with runs of whitespace collapsed outside strings: + * `@media (max-width: 75em) .book-banner .title`. + * + * Carried because a consumer may need to tell two occurrences of the same literal in + * one file apart — REX's baseline ratchet identifies an occurrence by the declaration + * it was written in. Consumers that only need to know a colour is wrong can ignore it. + */ + context: string; + /** + * The property name, e.g. `background-color`. Lower-cased, because CSS matches + * property names case-insensitively — except for a custom property such as + * `--tabs-border-color`, whose name is case-sensitive and is kept as written. + */ + property: string; + /** Everything to the right of the `:`. */ + value: string; +} + +/** A colour literal together with the declaration it was written in. */ +export interface StylesheetColor extends FoundColor { + context: string; + property: string; +} + +/** + * https://www.w3.org/TR/css-color-4/#named-colors + * + * Prototype-less, so a lookup can only answer for a colour that is actually in the + * table. An object literal inherits from `Object.prototype`, where `constructor` and + * `__proto__` are truthy — and they are the two inherited keys that survive being + * lower-cased, so `color: constructor` looked up to a function, was reported as a + * colour, and then crashed `fromHex` on `.toLowerCase()`. A crash here takes down the + * whole suite of whichever consumer is running the audit. + * + * Fixed on the table rather than at the two lookups, because guarding a call site only + * holds until someone adds a third. + */ +const NAMED_COLORS: Record = Object.assign(Object.create(null), { + aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', + azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', + blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', + burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e', + coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', + cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', + darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', darkviolet: '#9400d3', deeppink: '#ff1493', + deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969', dodgerblue: '#1e90ff', + firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', + gray: '#808080', green: '#008000', greenyellow: '#adff2f', grey: '#808080', + honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c', indigo: '#4b0082', + ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa', lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', + lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', + lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', + magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', + mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970', + mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5', navajowhite: '#ffdead', + navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23', + orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6', palegoldenrod: '#eee8aa', + palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093', + papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb', + plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399', + red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', + salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee', + sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd', + slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f', + steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', + tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3', + white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32', +}); + +/** + * Functions whose arguments *are* the colour, rather than containing one. These are + * terminal: we try to resolve them and report them either way. Anything else that + * happens to contain a colour (`var`, `color-mix`, the gradients) is descended into — + * see `COLOR_CONTAINERS` for which of those are colour-bearing by definition. + */ +const COLOR_FUNCTIONS = [ + 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', + 'device-cmyk', +]; + +/** + * Functions that contain colours rather than being one, and whose arguments are known + * to be colour-valued whatever property they sit in. `list-style-image` takes an image, + * but `linear-gradient(red, blue)` is still a gradient between two colours, so the + * property gate must not reach inside one. + * + * `var()` is deliberately absent: a fallback is whatever the property makes of it, so + * it keeps the gate of the property it was written in. + * + * `image()` is here for its second argument, which is a bare fallback ``: + * `image(url(marker.svg), red)` renders red if the marker fails to load. `image-set()` + * is deliberately *not* here -- it holds images and resolutions, never a colour. + */ +const COLOR_CONTAINERS = [ + 'linear-gradient', 'radial-gradient', 'conic-gradient', 'repeating-linear-gradient', + 'repeating-radial-gradient', 'repeating-conic-gradient', 'color-mix', 'light-dark', + 'cross-fade', 'image', +]; + +/** + * Keywords that are colour-valued but carry no fixed channels, so there is nothing to + * compare against a theme. They are never reported. + */ +const COLOR_KEYWORDS = [ + 'transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert', 'none', +]; + +/** + * Blanks the parts of a stylesheet that can hold colour-shaped text without meaning a + * colour: comments, string contents and `url()` payloads (which may carry a data: URI + * complete with `;` and `#`, and would otherwise wreck the declaration split). + * + * Blanked to spaces rather than deleted, so the result is the same length as the input + * and every character keeps its original index. `declarations` relies on that: it finds + * structure in the blanked text and then slices the corresponding span out of a second, + * differently-blanked copy. + * + * `keepStrings` is what that second copy is for. A string is noise inside a declaration + * value — `content: "#fff"` is not a colour — but it is *meaning* inside a selector: + * `[data-loading="true"]` and `[data-loading="false"]` are different rules, and blanking + * both to `[data-loading=""]` would merge them into one context. + */ +const blankNoise = (css: string, keepStrings: boolean): string => { + const pad = (length: number) => ' '.repeat(Math.max(0, length)); + let out = ''; + let index = 0; + + while (index < css.length) { + const rest = css.slice(index); + + // A CSS escape makes the character after it ordinary source text, so it has to be + // taken before anything that looks for a delimiter: `.foo\"bar` is a valid class + // name whose quote opens no string, and treating it as one blanks the rest of the + // stylesheet. Both characters are copied through rather than blanked — an escape in + // a value is part of an identifier, and `\red` really is the colour `red`. + if (css[index] === '\\') { + out += css.slice(index, index + 2); + index += 2; + continue; + } + + if (rest.startsWith('/*')) { + const end = css.indexOf('*/', index + 2); + const stop = end === -1 ? css.length : end + 2; + out += pad(stop - index); + index = stop; + continue; + } + + const quote = css[index]; + if (quote === '"' || quote === '\'') { + let cursor = index + 1; + while (cursor < css.length && css[cursor] !== quote) { + cursor += css[cursor] === '\\' ? 2 : 1; + } + const stop = Math.min(cursor + 1, css.length); + // blanked whole, quotes included: nothing downstream needs the quotes, and + // keeping them would have to handle an unterminated string running off the end. + out += keepStrings ? css.slice(index, stop) : pad(stop - index); + index = stop; + continue; + } + + // `url` has to be the whole function name rather than the tail of one. In + // `--x: myurl(#fff)` the payload is ordinary value text to descend into, and + // blanking it loses the colour. The preceding source character settles it: an + // ident character there means `url` is only a suffix. + const boundary = index === 0 || !/[\w-]/.test(css[index - 1]); + const url = boundary ? /^url\(/i.exec(rest) : null; + if (url) { + const open = index + url[0].length; + let depth = 1; + let cursor = open; + // only a structural `)` ends the url: `url("icon).svg")` closes at the last + // paren, not at the one in the filename. Stopping early would leave the trailing + // quote behind, and blanking that "unterminated string" would swallow every + // declaration after it. + while (cursor < css.length && depth > 0) { + const character = css[cursor]; + + if (character === '\\') { cursor += 2; continue; } + + if (character === '"' || character === '\'') { + cursor++; + while (cursor < css.length && css[cursor] !== character) { + cursor += css[cursor] === '\\' ? 2 : 1; + } + cursor++; + continue; + } + + if (character === '(') { depth++; } + if (character === ')') { depth--; } + cursor++; + } + // an escape or a quote at the very end can carry the cursor past the end, and the + // blanked copy has to stay the same length as the input. + cursor = Math.min(cursor, css.length); + // the parens themselves are structure — `declarations` balances them — so only + // the payload between them is blanked. + const closed = depth === 0; + const payloadEnd = closed ? cursor - 1 : cursor; + out += css.slice(index, open) + pad(payloadEnd - open) + (closed ? ')' : ''); + index = cursor; + continue; + } + + out += css[index]; + index++; + } + + return out; +}; + +/** Noise blanked for reading declaration values: strings go too. */ +export const stripNoise = (css: string): string => blankNoise(css, false); + +/** + * Collapses runs of whitespace in a selector, but only where the whitespace is + * separator rather than content. `[data-label="a b"]` and `[data-label="a b"]` match + * different values, so a context that collapsed both to the latter would stop telling + * two rules apart — which is the one job the context has. + */ +const collapseSeparators = (selector: string): string => { + let out = ''; + let index = 0; + + while (index < selector.length) { + const character = selector[index]; + + if (character === '\\') { + // an escaped space is part of an identifier, e.g. the class `.a\\ b` + out += selector.slice(index, index + 2); + index += 2; + continue; + } + + if (character === '"' || character === '\'') { + let cursor = index + 1; + while (cursor < selector.length && selector[cursor] !== character) { + cursor += selector[cursor] === '\\' ? 2 : 1; + } + const stop = Math.min(cursor + 1, selector.length); + out += selector.slice(index, stop); + index = stop; + continue; + } + + if (/\s/.test(character)) { + while (index < selector.length && /\s/.test(selector[index])) { index++; } + out += ' '; + continue; + } + + out += character; + index++; + } + + return out.trim(); +}; + +/** + * Pulls declarations out of a stylesheet at any nesting depth, so `@media` blocks are + * covered. Selectors and at-rule preludes end at a `{` and become the declaration's + * `context` rather than being read as declarations themselves, which is what keeps + * `a:hover` and `@keyframes` percentages out of the colour scan. + * + * The property name is kept as well as the value, because whether a bare identifier + * means a colour depends on the property it sits in — see `takesColor`. + * + * Two blanked copies of the source are walked in step. Structure is read from `values`, + * where strings are gone, so a `;` or `{` inside one cannot split a declaration. The + * `context` is sliced out of `selectors`, where string contents survive, so that + * `[data-loading="true"]` and `[data-loading="false"]` stay distinguishable. Both are + * the same length as the input, which is what lets one index address both. + */ +/** + * A declaration whose property is a custom property, up to and including its colon. + * This is what tells `--x: { red }`, a declaration whose value happens to be a block, + * from `a { ... }`, a nested rule. + */ +const CUSTOM_PROPERTY_DECLARATION = /^\s*--[^\s:]*\s*:/; + +export const declarations = (css: string): Declaration[] => { + const found: Declaration[] = []; + const values = stripNoise(css); + const selectors = blankNoise(css, true); + const stack: string[] = []; + let start = 0; + let parens = 0; + let blocks = 0; + + const flush = (end: number) => { + const segment = values.slice(start, end); + const separator = segment.indexOf(':'); + + if (stack.length > 0 && separator !== -1) { + const value = segment.slice(separator + 1).trim(); + const name = segment.slice(0, separator).trim(); + // ordinary property names are case-insensitive, but a custom property's is not: + // `--Brand` and `--brand` are two different properties and must stay two. + const property = name.startsWith('--') ? name : name.toLowerCase(); + if (value) { found.push({ context: stack.join(' '), property, value }); } + } + + start = end + 1; + }; + + for (let index = 0; index < values.length; index++) { + const character = values[index]; + + // same rule as in `blankNoise`, and needed again because this scanner reads the + // blanked copy, where escapes survive: `.foo\{bar` is one class name, so its brace + // must not open a block and its semicolon must not end a declaration. + if (character === '\\') { index++; continue; } + + if (character === '(') { parens++; } + if (character === ')') { parens = Math.max(0, parens - 1); } + if (parens !== 0) { continue; } + + // A custom property takes an arbitrary token stream, so a `{}` block in its value + // is a component value rather than a nested rule: `--x: { red }` is a declaration + // whose value is `{ red }`. Everything inside belongs to the value, `;` included, + // so the structural rules are suspended until the block closes. + if (blocks > 0) { + if (character === '{') { blocks++; } + if (character === '}') { blocks--; } + continue; + } + + if (character === '{') { + if (CUSTOM_PROPERTY_DECLARATION.test(values.slice(start, index))) { + blocks = 1; + continue; + } + stack.push(collapseSeparators(selectors.slice(start, index))); + start = index + 1; + } else if (character === '}') { + flush(index); + stack.pop(); + } else if (character === ';') { + flush(index); + } + } + + return found; +}; + +/** + * Properties whose value can hold a ``, directly or inside a shorthand. + * + * Hex and the colour functions are only ever colours, so they are read wherever they + * appear. A bare identifier is not: `animation-name: red` names a keyframe animation and + * `font-family: white` names a font, and reporting either as a palette violation would be + * wrong — with a suggested fix that would break the declaration. Named colours are + * therefore only read in these properties. + * + * Spelled out rather than matched by prefix, so that `border-radius`, `border-width` and + * the rest of the border family that cannot take a colour do not let one through. + * + * `list-style` is deliberately absent, though it is image-bearing: its bare identifier + * is a `` name, and after `@counter-style red { ... }` the declaration + * `list-style: red` is valid and means that counter. A gradient written there is still + * found, because a gradient opens the gate for its own stops — see `COLOR_CONTAINERS`. + */ +const COLOR_SHORTHANDS = [ + 'background', 'background-image', 'border', 'border-block', 'border-block-end', + 'border-block-start', 'border-bottom', 'border-image', 'border-image-source', + 'border-inline', 'border-inline-end', 'border-inline-start', 'border-left', + 'border-right', 'border-top', 'box-shadow', 'caret', 'column-rule', 'fill', 'filter', + 'backdrop-filter', 'mask', 'mask-image', 'outline', 'scrollbar', 'stroke', + 'text-decoration', 'text-emphasis', 'text-shadow', 'text-stroke', +]; + +/** + * Properties the `color` substring claims but that hold no ``. + * + * `color-scheme` is the one that actually produces a false finding: its value is an + * author-defined ``, so `color-scheme: red` names a scheme and reporting + * it as red would be wrong — with a suggested fix that breaks the declaration. The rest + * take fixed keywords, none of which is a colour name, so excluding them changes no + * result today; they are listed because the substring has no business claiming them and + * a future keyword could collide. + */ +const NOT_COLOR_PROPERTIES = [ + 'color-scheme', 'color-adjust', 'print-color-adjust', 'forced-color-adjust', + 'color-interpolation', 'color-interpolation-filters', 'color-rendering', +]; + +const unprefixed = (name: string) => name.replace(/^-(?:webkit|moz|ms|o)-/, ''); + +/** Whether a bare identifier in this property's value could be a colour. */ +export const takesColor = (property: string): boolean => { + // custom properties have no grammar to go on, so anything in one counts + if (property.startsWith('--')) { return true; } + + const name = unprefixed(property.toLowerCase()); + + if (NOT_COLOR_PROPERTIES.includes(name)) { return false; } + + return name.includes('color') || COLOR_SHORTHANDS.includes(name); +}; + +/** Whether this function's arguments are colours regardless of the enclosing property. */ +const holdsColor = (fn: string): boolean => COLOR_CONTAINERS.includes(unprefixed(fn)); + +const clamp = (value: number, max: number) => Math.min(max, Math.max(0, value)); + +/** + * The CSS `` grammar, shared by the channels and the alpha rather than + * approximated as "digits and dots". `[\d.]+` also matches `.` and `1..2`, which + * `parseFloat` turns into `NaN` and a truncated `1`; both would then be handed back as + * resolved channels, so a malformed declaration would read as a real colour and get a + * comparison key built out of `NaN`. + */ +const NUMBER = '[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?'; +const IS_NUMBER = new RegExp(`^${NUMBER}$`, 'i'); +const IS_PERCENTAGE = new RegExp(`^(${NUMBER})%$`, 'i'); + +const channel = (raw: string): number | null => { + const text = raw.trim(); + const percent = IS_PERCENTAGE.exec(text); + // scale by 255/100 rather than by the decimal 2.55, which is not representable in + // binary: 50 * 2.55 is 127.49999999999999 and rounds to 127, where 50% of 255 is + // 127.5 and rounds to 128. The two spellings of the same colour must agree, or they + // get different keys and the audit misclassifies one of them. + if (percent) { return Math.round((clamp(parseFloat(percent[1]), 100) / 100) * 255); } + return IS_NUMBER.test(text) ? Math.round(clamp(parseFloat(text), 255)) : null; +}; + +const alphaChannel = (raw?: string): number | null => { + if (raw === undefined) { return 1; } + const text = raw.trim(); + const percent = IS_PERCENTAGE.exec(text); + if (percent) { return clamp(parseFloat(percent[1]), 100) / 100; } + return IS_NUMBER.test(text) ? clamp(parseFloat(text), 1) : null; +}; + +/** + * Only the four lengths CSS defines, and only hex digits. Checking the grammar rather + * than just the expanded length matters: `#ggg` would otherwise expand to six characters, + * `parseInt` them to NaN, and hand back an Rgba of NaNs that reads as a resolved colour. + * A malformed *theme* value would then pass the "every colour token resolves" guard while + * generating invalid CSS — the one check meant to stop a palette value from silently + * becoming unmatchable. + */ +const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/; + +const fromHex = (literal: string): Rgba | null => { + if (!HEX.test(literal.toLowerCase())) { return null; } + + const digits = literal.slice(1); + const expand = (text: string) => text.split('').map((c) => c + c).join(''); + const full = digits.length === 3 || digits.length === 4 ? expand(digits) : digits; + + return { + r: parseInt(full.slice(0, 2), 16), + g: parseInt(full.slice(2, 4), 16), + b: parseInt(full.slice(4, 6), 16), + a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1, + }; +}; + +/** + * Splits `rgb()`/`rgba()` arguments, or null when none of the productions hold. + * + * The two syntaxes are parsed separately rather than by normalising the separators + * away, because they are four distinct productions and not interchangeable: + * + * rgb( {3} [ / ]? ) rgb( #{3} , ? ) + * rgb( {3} [ / ]? ) rgb( #{3} , ? ) + * + * Two consequences, both checked against Chromium rather than read off the grammar: + * the modern syntax puts its alpha behind exactly one slash, so `rgb(0 0 0 0.5)` is + * four channels and not a colour; and each production takes three channels of *one* + * type, so `rgb(0, 50%, 0)` and `rgb(0 50% 0)` are both invalid. Resolving either + * would let a malformed theme value pass the consumer's "every colour token resolves" + * guard while generating CSS the browser drops on the floor. + */ +const rgbaArgs = (raw: string): string[] | null => { + const consistent = (channels: string[]) => { + const percentages = channels.filter((c) => c.trim().endsWith('%')).length; + return percentages === 0 || percentages === channels.length; + }; + + if (raw.includes(',')) { + // the legacy syntax has no slash anywhere + if (raw.includes('/')) { return null; } + + const parts = raw.split(','); + if (parts.length < 3 || parts.length > 4) { return null; } + + return consistent(parts.slice(0, 3)) ? parts : null; + } + + const [channels, alpha, ...extra] = raw.split('/'); + if (extra.length > 0) { return null; } + + const parts = channels.trim().split(/\s+/); + if (parts.length !== 3 || !consistent(parts)) { return null; } + if (alpha === undefined) { return parts; } + + // a slash with nothing after it is not an alpha + return alpha.trim() === '' ? null : [...parts, alpha]; +}; + +/** + * One CSS escape: a backslash and up to six hex digits, whose terminating whitespace + * belongs to the escape, or a backslash and any single character bar a newline. + * + * The six-digit limit is the whole subtlety. `e` and `d` are hex digits, so `\72ed` is + * U+72ED rather than `r` followed by `ed` — Chromium rejects `color: \72ed` outright. + * Stopping short of six would invent a finding out of valid CSS. + */ +const ESCAPE = /^\\(?:([0-9a-fA-F]{1,6})[ \t\n\r\f]?|([^\n\r\f]))/; + +/** An identifier cannot start with a digit; a leading `-` or an escape is fine. */ +const IDENT_START = /[a-zA-Z_\u0080-\uffff\\-]/; +const IDENT_CHARACTER = /[a-zA-Z0-9_\u0080-\uffff-]/; + +/** + * The source span of one CSS identifier at `from`, or null if there is not one there. + * + * Escapes are part of an identifier, so the span can be longer than the name it spells: + * `r\65 d` is five characters of source for the three of `red`. The span rather than + * the name is what a consumer has to find and rewrite in the file, so both are kept -- + * `decodeEscapes` is the other half. + */ +const identSpan = (value: string, from: number): string | null => { + if (!IDENT_START.test(value[from] ?? '')) { return null; } + + let index = from; + + while (index < value.length) { + if (value[index] === '\\') { + const escape = ESCAPE.exec(value.slice(index)); + // a backslash before a newline is not an escape, and ends the identifier + if (escape === null) { break; } + index += escape[0].length; + continue; + } + + if (!IDENT_CHARACTER.test(value[index])) { break; } + index++; + } + + return index === from ? null : value.slice(from, index); +}; + +/** + * Decodes CSS escapes, so that the audit reads the identifier CSS reads. `r\65 d`, + * `re\64`, `\red` and `\72 ed` are four spellings of `red` and all four resolve to it + * in Chromium, so an audit that reads them as separate words is trivially bypassed. + */ +const decodeEscapes = (text: string): string => { + if (!text.includes('\\')) { return text; } + + let out = ''; + let index = 0; + + while (index < text.length) { + const escape = text[index] === '\\' ? ESCAPE.exec(text.slice(index)) : null; + + if (escape === null) { + out += text[index]; + index++; + continue; + } + + if (escape[1] === undefined) { + out += escape[2]; + } else { + const point = parseInt(escape[1], 16); + // null, a surrogate and anything past the last plane all become the replacement + // character, which is what CSS says and also stops fromCodePoint throwing + out += point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff) + ? '\ufffd' + : String.fromCodePoint(point); + } + + index += escape[0].length; + } + + return out; +}; + +/** + * Resolves a colour literal to channels, or null when it cannot be resolved statically. + * Returning null is deliberate: `hsl()`, `oklch()` and `color()` are reported by the + * consumer rather than passing silently, so the escape hatch stays explicit. + */ +export const describeColor = (literal: string): Rgba | null => { + const text = literal.trim(); + + if (text.startsWith('#')) { return fromHex(text.toLowerCase()); } + + const named = NAMED_COLORS[decodeEscapes(text).toLowerCase()]; + if (named) { return fromHex(named); } + + const fn = /^(rgba?)\((.*)\)$/is.exec(text); + if (!fn) { return null; } + + const args = rgbaArgs(fn[2]); + if (args === null) { return null; } + + const [r, g, b] = args.slice(0, 3).map(channel); + const a = alphaChannel(args[3]); + + return r === null || g === null || b === null || a === null ? null : { r, g, b, a }; +}; + +/** + * The colour walk itself, over a value whose noise has already been blanked. + * + * Split from `findColors` because the two have different preconditions rather than + * different behaviour: this one requires a blanked value and is also how it recurses + * into its own arguments, where re-blanking would be wasted work. + */ +const scanColors = (value: string, named: boolean): FoundColor[] => { + const found: FoundColor[] = []; + let index = 0; + + while (index < value.length) { + const rest = value.slice(index); + + // the leading `-` matters: without it `-webkit-linear-gradient(...)` is not read as + // a call at all, and its stops are walked as if they were loose identifiers. + const call = /^(-?[a-z][\w-]*)\(/i.exec(rest); + if (call) { + let depth = 1; + let cursor = index + call[0].length; + while (cursor < value.length && depth > 0) { + if (value[cursor] === '(') { depth++; } + if (value[cursor] === ')') { depth--; } + cursor++; + } + const literal = value.slice(index, cursor); + const args = literal.slice(call[0].length, literal.endsWith(')') ? -1 : undefined); + const fn = call[1].toLowerCase(); + + if (COLOR_FUNCTIONS.includes(fn)) { + found.push({ literal, rgba: describeColor(literal) }); + } else { + // a colour-bearing container opens the gate for its arguments; anything else + // just passes the enclosing property's gate down unchanged. + found.push(...scanColors(args, named || holdsColor(fn))); + } + + index = cursor; + continue; + } + + const hex = /^#[0-9a-fA-F]{3,8}\b/.exec(rest); + if (hex) { + found.push({ literal: hex[0], rgba: describeColor(hex[0]) }); + index += hex[0].length; + continue; + } + + const ident = identSpan(value, index); + if (ident) { + const name = decodeEscapes(ident).toLowerCase(); + if (named && NAMED_COLORS[name] && !COLOR_KEYWORDS.includes(name)) { + found.push({ literal: ident, rgba: describeColor(ident) }); + } + index += ident.length; + continue; + } + + index++; + } + + return found; +}; + +/** + * Finds every colour literal in a declaration value, at any depth. Functions that merely + * contain colours are descended into; colour functions are terminal. + * + * `named` says whether a bare identifier may be read as a colour. That depends on the + * property the value belongs to — see `takesColor` — and on whether the walk has since + * descended into a function whose arguments are colours whatever the property is, see + * `COLOR_CONTAINERS`. Hex and the colour functions are unambiguous and are found either + * way. It has no default: defaulting it to `true` would quietly restore the over-eager + * behaviour for any caller that forgot it. + * + * The value is taken *raw*, as written in the source, and its noise is blanked here. + * Without that this took a declaration value in its doc comment and a noise-free one in + * fact: `url(#fff)` is a URL whose fragment is not a colour and `content: "red"` is a + * string, yet both were reported. `stylesheetColors` does not pay for this twice — it + * reads values that `declarations` has already blanked, so it walks them directly. + */ +export const findColors = (value: string, named: boolean): FoundColor[] => + scanColors(stripNoise(value), named); + +/** + * Every colour literal written in a stylesheet, in source order. + * + * Accumulated with `push` rather than by spreading into a new array per declaration: + * this runs over every stylesheet in a tree, so the quadratic version was copying every + * colour found so far once per subsequent declaration. + */ +export const stylesheetColors = (css: string): StylesheetColor[] => { + const found: StylesheetColor[] = []; + + for (const { context, property, value } of declarations(css)) { + for (const color of scanColors(value, takesColor(property))) { + found.push({ ...color, context, property }); + } + } + + return found; +}; + +const hex = (rgba: Rgba) => + `#${[rgba.r, rgba.g, rgba.b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; + +/** + * Canonical key for comparing two colours, and the form a consumer's allowlist is keyed + * by. `#rrggbb` for an opaque colour, with the alpha appended when there is one. + * + * Hex rather than the raw channels because this is the form that ends up in an allowlist + * and in a failure message, where a reader has to recognise it. Alpha stays decimal + * rather than becoming a fourth hex pair, which would round two distinct alphas onto one + * key. + */ +export const colorKey = (rgba: Rgba): string => + rgba.a === 1 ? hex(rgba) : `${hex(rgba)}/${rgba.a}`; + +/** Key ignoring alpha, so `rgba(0, 0, 0, 0.2)` can be recognised as the theme's black. */ +export const opaqueKey = (rgba: Rgba): string => hex(rgba);