import assert from 'node:assert/strict'; import { test } from 'node:test'; import { MONO_WEIGHT_CAP, blankComments, describeFinding, findIndirectWeights, findOffScaleWeights, findWorkspaceWeights, isScannedFile, nearestScaleWeight, scanWeights, validateScanCoverage, } from './check-font-weights.mjs'; import { decodeEscapes } from './font-weight-lexer.mjs'; const offScale = (file, source) => findOffScaleWeights(file, source).findings.map( ({ line, name, value }) => `${line} ${name}: ${value}` ); /** The findings across `files` (`{ path: source }`), scanned together. */ const workspace = (files) => findWorkspaceWeights( Object.entries(files).map(([file, source]) => ({ file, source })) ).findings.map( ({ file, line, name, value }) => `${file}:${line} ${name}: ${value}` ); test('flags an off-scale font-weight with its line number', () => { const source = ['.title {', ' font-weight: 650;', '}'].join('\n'); assert.deepEqual(offScale('libs/a.component.scss', source), [ '2 font-weight: 650', ]); }); test('accepts every scale weight and the keywords that resolve onto it', () => { const source = [ 'a { font-weight: 400; }', 'b { font-weight: 500 !important; }', 'c { font-weight: 600; }', 'd { font-weight: 700; }', 'e { font-weight: normal; }', 'f { font-weight: bold; }', 'g { font-weight: inherit; }', 'h { font-weight: $title-weight; }', 'i { font-weight: var(--error-view-title-weight); }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), []); }); test('flags weights fed through custom properties, Sass and token maps', () => { const source = [ '.view {', ' --error-empty-title-weight: 610;', ' --error-view-title-weight: var(--error-empty-title-weight, 640);', '}', '@include detail-view.base(', ' $season-title-font-weight: 650,', ');', '@include mat.button-overrides((label-text-weight: 530));', '@include heading($title-weight: 650) {', ' color: red;', '}', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), [ '2 --error-empty-title-weight: 610', '3 --error-view-title-weight: 640', '6 $season-title-font-weight: 650', '8 label-text-weight: 530', '9 $title-weight: 650', ]); }); test('reads the weight of a font shorthand but not its line height', () => { const source = [ '.a { font: 600 10.5px $font-mono; }', '.b { font: 800 12px/1 sans-serif; }', '.d { font: inherit; }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), ['2 font: 800']); }); test('reads a value wrapped over several lines to its end', () => { const source = [ '.a {', ' font-weight: var(--title-weight,', ' 650);', '}', '.b {', ' font-weight:', ' var(', ' --title-weight,', ' 750', ' );', '}', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), [ '2 font-weight: 650', '6 font-weight: 750', ]); }); test('stops a value at the end of its map entry or style string', () => { const map = [ '@include mat.button-overrides(', ' (', ' label-text-weight: 600,', ' label-text-line-height: 1,', ' )', ');', ].join('\n'); const component = [ "const style = 'font-weight: 600';", 'const timeout = 300;', ].join('\n'); const template = '

Top 100 channels

'; assert.deepEqual(offScale('libs/a.scss', map), []); assert.deepEqual(offScale('apps/web/src/a.ts', component), []); assert.deepEqual(offScale('apps/web/src/a.html', template), []); }); test('checks the font shorthand in TypeScript, not a font property', () => { const component = [ "styles: ['.chip { font: 650 12px sans-serif; }'],", 'const chart = { font: 12, legend: { font: { size: 12 } } };', "const inherit = { font: 'inherit' };", ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 font: 650', ]); }); test('compares every CSS number form with the scale', () => { const source = [ '.a { font-weight: 650.0; }', '.b { font-weight: 600.0; }', '.c { font-weight: 6.5e2; }', '.d { font-weight: +700; }', '.e { font: 520.5 12px sans-serif; }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), [ '1 font-weight: 650.0', '3 font-weight: 6.5e2', '5 font: 520.5', ]); }); test('reads a declaration to its terminator however long it is', () => { const source = `.a { font-weight: /* ${'x'.repeat(600)} */ 650; }`; assert.deepEqual(offScale('libs/a.scss', source), ['1 font-weight: 650']); }); test('flags relative keywords in the font shorthand', () => { const source = '.a { font: bolder 1rem sans-serif; }'; assert.deepEqual(offScale('libs/a.scss', source), ['1 font: bolder']); }); test('follows variables a weight refers to, through chains and files', () => { const source = [ ':root { --title: var(--title-base); --title-base: 650; --gap: 3; }', '$heading: 750;', '.a { font-weight: var(--title); gap: var(--gap); }', '.b { font-weight: $heading; }', '.c { font: 600 var(--size) $family; }', ':root { --size: 12; }', '$family: 1;', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), [ '2 $heading: 750', '1 --title-base: 650', ]); const tokens = scanWeights('libs/tokens.scss', ':root { --title: 650; }'); const usage = scanWeights( 'libs/b.component.scss', '.b { font-weight: var(--title); }' ); assert.deepEqual( findIndirectWeights([tokens, usage]).map( ({ file, line, value }) => `${file}:${line} ${value}` ), ['libs/tokens.scss:1 650'] ); }); test('checks Angular style bindings and literal DOM writes', () => { const template = [ '

a

', '

b

', ].join('\n'); const component = [ "host: { '[style.font-weight]': '750' },", "element.style.fontWeight = '650';", "element.style.setProperty('font-weight', '450');", 'element.style.fontWeight = weight;', ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ '1 [style.font-weight]: 650', ]); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 [style.font-weight]: 750', '2 .style.fontWeight: 650', "3 setProperty('font-weight': 450", ]); }); test('reads `//` after `:` or `(` as a Sass comment outside url()', () => { const stylesheet = [ '.x { font-weight:// old 650', '600; }', '.y { font-weight: (// note 650', '600); }', '.z { background: url(http://cdn.test/a.png); font-weight: 750; }', '.w { color: red; } // a note that ends in url(', '// font-weight: 650 was the old value', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', stylesheet), [ '5 font-weight: 750', ]); }); test('reads a TypeScript style object value as code', () => { const component = [ 'const style = { fontWeight: viewportWidth >= 768 ? 700 : 600 };', "const other = { 'font-weight': wide ? 650 : 400, color: 'red' };", 'const css = `font-weight: ${w}; font: 650 12px x`;', 'fontWeight: number;', "const keyed = { ['font-weight']: 650, [`fontWeight`]: 750 };", ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.ts', component).sort(), [ '2 font-weight: 650', '3 font: 650', '5 font-weight: 650', '5 fontWeight: 750', ] ); // HTML outside attributes is markup, not code: a `' ), [] ); }); test('treats JetBrains Mono after an always-available family as a fallback', () => { const source = [ ".a { font: 700 16px Roboto, 'JetBrains Mono'; }", ".b { font-family: monospace, 'JetBrains Mono'; font-weight: 700; }", ".c { font-family: 'SF Mono', 'JetBrains Mono'; font-weight: 700; }", ".d { font: 700 12px/1.4 'JetBrains Mono'; }", ".e { font: 700 12px / 1.4 'JetBrains Mono'; }", ".f { font: 700 12px / 1.4 Roboto, 'JetBrains Mono'; }", ".g { font: 700 12px /1.4 Roboto, 'JetBrains Mono'; }", ".h { font-family: 'Roboto', 'JetBrains Mono'; font-weight: 700; }", // DM Sans is Latin-only: Russian or Greek text falls through to Mono. ".i { font-family: 'DM Sans', 'JetBrains Mono'; font-weight: 700; }", // A size from a variable: the family follows it. ".j { font: 700 var(--size, 12px) 'JetBrains Mono'; }", ".k { font: 700 var(--size, 12px) Roboto, 'JetBrains Mono'; }", ".l { font: 700 $size 'JetBrains Mono'; }", ".m { font: 700 var(--size) 'JetBrains Mono'; }", // The name is matched whole. ".n { font-family: 'Not JetBrains Mono'; font-weight: 700; }", // `emoji` covers special glyphs only; text falls through to Mono. ".o { font-family: emoji, 'JetBrains Mono'; font-weight: 700; }", ].join('\n'); assert.deepEqual(offScale('libs/m6/a.scss', source).sort(), [ '10 font: 700', '12 font: 700', '13 font: 700', '15 font-weight: 700', '3 font-weight: 700', '4 font: 700', '5 font: 700', '9 font-weight: 700', ]); assert.deepEqual( offScale( 'apps/web/src/a.component.html', `
` ), [] ); }); test('reads a family name as the browser does', () => { const source = [ // Escapes decode: these all name JetBrains Mono. '.a { font-family: JetBrains\\ Mono; font-weight: 700; }', '.b { font-family: \\4a etBrains Mono; font-weight: 700; }', ".c { font-family: 'JetBrains\\20 Mono'; font-weight: 700; }", '.d { font: 700 12px JetBrains\\ Mono; }', // A quoted generic is a family name nobody has; an escaped one is // still the keyword. ".e { font-family: 'monospace', 'JetBrains Mono'; font-weight: 700; }", ".f { font-family: \\73 erif, 'JetBrains Mono'; font-weight: 700; }", // A string keeps its spaces and commas: neither is JetBrains Mono. ".g { font-family: 'JetBrains Mono'; font-weight: 700; }", ".h { font-family: 'JetBrains Mono, x'; font-weight: 700; }", // Unquoted words join with one space; an escaped one is kept. '.i { font-family: JetBrains Mono; font-weight: 700; }', '.j { font: 700 12px JetBrains\\ Mono; }', // Not one name: CSS drops the declaration. ".k { font-family: 'JetBrains Mono' Bold; font-weight: 700; }", '.l { font-family: JetBrains\\\n Mono; font-weight: 700; }', // A string's escaped quote is text; a line break in a string // continues it when escaped and ends it (unclosed) otherwise. ".m { font-family: 'x\\', JetBrains Mono, y'; font-weight: 700; }", ".n { font-family: 'JetBrains\\\n Mono'; font-weight: 700; }", ".o { font-family: 'JetBrains Mono\n; font-weight: 700; }", // An escape past the last code point is U+FFFD. '.p { font-family: \\110000 JetBrains Mono; font-weight: 700; }', ].join('\n'); assert.deepEqual(offScale('libs/m6/escapes.scss', source).sort(), [ '1 font-weight: 700', '16 font-weight: 700', '2 font-weight: 700', '3 font-weight: 700', '4 font: 700', '5 font-weight: 700', '9 font-weight: 700', ]); }); test('reads escaped names as Sass and the browser do', () => { const source = [ '.a { font-w\\65 ight: 650; fo\\6et-weight: 750; }', '.b { font\\2d weight: 650; font-\\77 eight: 750; }', '.c { --w\\65 ight: 650; font-weight: var(--weight); }', '$w\\65: 750; .d { font-weight: $we; }', ".e { font-family: 'JetBrains Mono'; font-weight: b\\6f ld; }", // An escape that ends at a line break keeps later lines in place. '.f { font-w\\65', 'ight: 650; }', '.g { font-weight: 750; }', ].join('\n'); assert.deepEqual(offScale('libs/m6/escaped.scss', source).sort(), [ '1 font-weight: 650', '1 font-weight: 750', '2 font-weight: 650', '2 font-weight: 750', '3 --weight: 650', '4 $we: 750', '5 font-weight: bold', '6 font-weight: 650', '8 font-weight: 750', ]); assert.deepEqual( offScale( 'apps/web/src/a.component.html', '

x

' ), ['1 font-weight: 650'] ); // An escape stays where its character would change the token: a digit // starting a name, anything after a number, a character no name has, // and past ASCII. TypeScript strings use JavaScript escapes instead. const kept = [ '\\36 50', '6\\35 0', '6\\65 2', '-\\35', '.a\\:b', "'\\\\65'", 'JetBrains\\20 Mono', 'a\\10061', ].join(' '); assert.equal(decodeEscapes('a.scss', kept).text, kept); assert.equal( decodeEscapes('a.scss', '-\\77 x a\\31, \\5f x -w\\65 bkit').text, '-wx a1, _x -webkit' ); assert.equal( decodeEscapes('a.ts', 'font-w\\65 ight').text, 'font-w\\65 ight' ); // A comment is no CSS, so an escape there cannot take the line break. assert.deepEqual( offScale('libs/m6/note.scss', '// note \\65\n.a { font-weight: 650; }'), ['2 font-weight: 650'] ); }); test('reads markup character references as the browser does', () => { const template = [ '

', '

', '

', '

', '

', "

", // A decoded line break keeps later lines in place. '

', // Not decoded: `', '

', '', '', // `<` and `>` stay text: no tag opens or closes. '

<i style="font-weight: 650">

', '

', // Past the last code point is U+FFFD. '

', ].join('\n'); const svg = [ 'x', '', '', ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.html', template).sort(), [ '1 font-weight: 650', '13 font-weight: 750', '14 font-weight: 650', '2 font-weight: 650', '3 font-weight: 750', '4 font-weight: 650', '5 font-weight: 700', '6 font-weight: 700', '7 font-weight: 650', ] ); // Lines hold through references that decode to two UTF-16 units, and // through references and escapes decoded together. const emoji = '😀'.repeat(30); assert.deepEqual( offScale( 'apps/web/src/a.component.html', `

\n\n` ), ['2 font-weight: 650'] ); assert.deepEqual( offScale( 'apps/web/src/a.component.html', '

\n' ), ['2 font-weight: 650'] ); assert.deepEqual(offScale('apps/web/src/assets/icon.svg', svg).sort(), [ '1 font-weight: 650', '2 font-weight: 750', ]); }); test('reads CSS text a string leaves to code', () => { const template = [ `
`, `
`, `
`, `
`, // An escaped quote stays in the string. `
`, // The rest of the expression is not part of the value. `
`, `
`, `
`, // Only a `+` concatenates. `
`, // A blank string adds nothing. `
`, `
`, ].join('\n'); const component = [ "el.style.cssText = 'font-weight:' + 650;", "el.style.cssText = 'font-weight: ' + (wide ? 750 : 600) + '; x: y';", "el.style.cssText = 'font-weight:' + w;", ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.html', template).sort(), [ '1 font-weight: 650', '10 font-weight: 650', '11 font-weight: 750', '2 font-weight: 750', '3 font: 650', '4 font-weight: 600', '5 font-weight: 650', ] ); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 font-weight: 650', '2 font-weight: 750', ]); }); test('reads Sass nested font properties', () => { const source = [ ".x { font: { family: 'JetBrains Mono'; weight: 700; } }", ".z { font-family: 'JetBrains Mono'; font: { weight: 700; } }", ".w { font: { family: 'JetBrains Mono'; } font-weight: 700; }", '.y { font: 12px { weight: 650; } }', '.u { font: { family: Roboto; weight: 700; } }', // `family`/`weight` outside a `font` block are other properties. ".t { family: 'JetBrains Mono'; font-weight: 700; }", // A nested weight and the rule's own replace each other in order; // a bare `weight:` is no weight at all. ".r { font-family: 'JetBrains Mono'; font: { weight: 700; } font-weight: 500; }", ".s { font-family: 'JetBrains Mono'; font-weight: 700; weight: 400; }", ].join('\n'); assert.deepEqual(offScale('libs/n4/a.scss', source).sort(), [ '1 weight: 700', '2 weight: 700', '3 font-weight: 700', '4 weight: 650', '8 font-weight: 700', ]); }); test('reads CSS in markup only where it styles', () => { const template = [ 'font-weight: 650', '

x

', '

x

', '', `
`, '', // A CSS string holds no comment; after `` it is markup again. '', '

/* text

', '

y

', 'font-weight: 650', // An unquoted `style` value runs to a space or `>`. '
x
', '
y
', '
z
', // Text that reads like an attribute, and a non-style binding. '

Set style=font-weight:650 here

', `
`, ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.html', template).sort(), [ '11 font-weight: 650', '3 font-weight: 650', '4 font-weight: 750', '5 font-weight: 650', '7 font-weight: 750', '9 font-weight: 650', ] ); }); test('reads @property initial values', () => { const report = (body) => findOffScaleWeights('libs/p9/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); assert.deepEqual( report( "@property --title-weight { syntax: ''; inherits: false; initial-value: 650; } .x { font-weight: var(--title-weight); }" ), ['--title-weight: 650'] ); assert.deepEqual( report( "@property --w { syntax: ''; initial-value: 750; inherits: false; } .y { font-weight: var(--w); }" ), ['--w: 750'] ); // A `` takes an exponent; an `` takes neither an // exponent nor a fraction. assert.deepEqual( report( "@property --w { syntax: ''; inherits: false; initial-value: 6.5e2; } .y { font-weight: var(--w); }" ), ['--w: 6.5e2'] ); assert.deepEqual( report( "@property --w { syntax: ''; inherits: false; initial-value: 650; } .y { font-weight: var(--w); }" ), ['--w: 650'] ); assert.deepEqual( report( "@property --w { syntax: ''; inherits: false; initial-value: 6.5e2; } .y { font-weight: var(--w, 600); }" ), [] ); // A math function of plain numbers is a valid initial value, and a // computed weight; one with a unit is not valid. assert.deepEqual( report( "@property --w { syntax: ''; inherits: false; initial-value: calc(600 + 50); } .y { font-weight: var(--w); }" ), ['--w: calc(600 + 50)'] ); assert.deepEqual( report( "@property --w { syntax: ''; inherits: false; initial-value: calc(1px + 2px); } .y { font-weight: var(--w, 600); }" ), [] ); assert.deepEqual( report( "@property --w { syntax: ''; inherits: false; initial-value: calc(50% + 600); } .y { font-weight: var(--w, 600); }" ), [] ); // It parses as CSS does: arguments per function, and spaces around a // `+` or `-`. Each value's validity is what Chromium makes of it. const math = { valid: [ ...['calc(600 - -50)', 'calc(600 * 1.08)', 'calc((600 + 50))'], ...['round(up, 649.2)', 'round(649.6, 10)', 'log(e)', 'max(650)'], ...['calc(pi * 207)', 'MAX(600, 650)', 'calc(1300 / 2)'], ...['hypot(650)', 'calc(1e3 - 350)', 'clamp(600, 650, 700)'], ], invalid: [ ...['min()', 'calc()', 'calc(600+50)', 'calc(600 +50)'], ...['calc(600+ 50)', 'calc(600 +(50))', 'clamp(600, 650)'], ...['clamp(600, 650, 700, 800)', 'max(600, )', 'calc(600 650)'], ...['calc(650)%', 'calc(600 + 50))', 'round(up 649)', 'foo(650)'], ...['calc(600 $ 50)', 'round(up / 1, 649)'], ], }; const registered = (value) => report( `@property --w { syntax: ''; inherits: false; initial-value: ${value}; } .y { font-weight: var(--w, 600); }` ); for (const value of math.valid) { assert.deepEqual(registered(value), [`--w: ${value}`], value); } for (const value of math.invalid) { assert.deepEqual(registered(value), [], value); } // CSS ignores an invalid registration: a syntax that rejects the value, // or a missing `inherits`. assert.deepEqual( report( "@property --title-weight { syntax: ''; inherits: false; initial-value: 650; } .x { font-weight: var(--title-weight, 600); }" ), [] ); assert.deepEqual( report( "@property --w { syntax: ''; initial-value: 750; } .y { font-weight: var(--w, 600); }" ), [] ); const reader = scanWeights( 'libs/p9/b.scss', ".b { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }" ); assert.deepEqual( findIndirectWeights([ scanWeights( 'libs/p9/_props.scss', "@property --face { syntax: ''; inherits: true; initial-value: Roboto; }" ), reader, ]).map(({ value }) => value), ['700'] ); // A registered property always has a value, so a fallback never // applies, wherever it is registered. assert.deepEqual( findIndirectWeights([ scanWeights( 'libs/p9/_props.scss', "@property --face { syntax: '*'; inherits: true; initial-value: Roboto; }" ), scanWeights( 'libs/p9/b.scss', ".b { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }" ), ]), [] ); }); test('checks SVG presentation attributes and styles', () => { const svg = [ 'x', '', 'y', ].join('\n'); assert.deepEqual(offScale('apps/web/src/assets/logo.svg', svg).sort(), [ '1 font-weight: 650', '2 font-weight: 750', ]); }); test('caps inline JetBrains Mono declarations', () => { const template = [ `
`, `
`, '
', `
`, `
`, `
`, ].join('\n'); const component = [ `element.style.font = "700 16px 'JetBrains Mono'";`, "styles: [`.a { font-family: 'JetBrains Mono'; } .b { font-weight: 700; }`],", "styles: [`.c { font-family: 'JetBrains Mono'; font-weight: 600; }`],", ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ '2 font-weight: 700', '5 font-weight: 700', '6 font-weight: 700', '1 font: 700', ]); assert.deepEqual( offScale('apps/web/src/a.component.ts', component).sort(), ['1 .style.font: 700', '3 font-weight: 600'] ); }); test('reads Angular style bindings as code', () => { const template = [ '
', `
`, `
`, '

text

', `
`, // A string in a binding is CSS, so `/200` is a line-height. '
', `
`, ].join('\n'); const component = 'template: `
`,'; assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ '2 font-weight: 650', '3 font-weight: 750', '4 font-weight: 650', ]); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), []); }); test('reads a feature query test as a condition, not a declaration', () => { const source = [ '@supports (font-weight: 650) { .x { font-weight: 600; } }', '@supports (min-width: #{10}px) and (font-weight: 650) { .v { font-weight: 600; } }', '@supports (font: 650 1px x) { .y { font: 600 12px x; } }', '@container style(--w: 650) { .z { font-weight: var(--w); } }', '@mixin m($title-weight: 400) { font-weight: $title-weight; }', '.w { @include m($title-weight: 650); }', ].join('\n'); assert.deepEqual(offScale('libs/q3/a.scss', source), [ '6 $title-weight: 650', ]); }); test('reads an interpolated name every way it can compose', () => { assert.deepEqual( offScale( 'libs/q3/c.scss', '$prop: font; @if $dark { $prop: font-weight; } .x { #{$prop}: 750; }' ), ['1 #{$prop}: 750'] ); }); test('checks property names Sass builds by interpolation', () => { const source = [ '$prop: font-weight;', '.z { #{$prop}: 650; }', '.x { font-#{weight}: 650; }', '.y { #{"font-weight"}: 750; }', '.w { margin-#{left}: 650px; }', '.v { font-#{$elsewhere}: 650; }', '.u { font-#{weight}: 600; }', // Another module's variable is not guessed; a literal `…weight` // tail is checked as written, once. '.t { font#{$elsewhere}: 650 12px x; }', '.s { #{$elsewhere}-weight: 650; }', '$pre: title; .r { #{$pre}-weight: 650; }', // A variable is read as it stands where the name is built. '$p: font-weight; .a { #{$p}: 650; } $p: margin-left;', '$q: margin-left; .b { #{$q}: 650; } $q: font-weight;', '$r: font-weight; .c { $r: color; } .d { #{$r}: 650; }', ].join('\n'); assert.deepEqual(offScale('libs/q3/b.scss', source).sort(), [ '10 -weight: 650', '11 #{$p}: 650', '13 #{$r}: 650', '2 #{$prop}: 650', '3 font-#{weight}: 650', '4 #{"font-weight"}: 750', '9 -weight: 650', ]); }); test('follows Sass loop variables', () => { const source = [ '@each $w in 400, 650 { .x-#{$w} { font-weight: $w; } }', '@each $w in 400, 600 { .y-#{$w} { font-weight: $w; } }', '@each $n, $w in (light: 400, heavy: 650) { .t-#{$n} { font-weight: $w; } }', '$weights: 400, 750;', '@each $w in $weights { .u-#{$w} { font-weight: $w; } }', '@for $w from 400 through 402 { .z-#{$w} { font-weight: $w; } }', '@for $w from 600 through 600 { .s { font-weight: $w; } }', '@for $i from 1 through 3 { .m-#{$i} { margin: $i * 4px; } }', '@for $w from 650 through 650 { .r { font-weight: $w; } }', // Inside its loop the variable shadows an outer one; after it, the // outer one is back. '$o: 650; @each $o in 400, 600 { .i { font-weight: $o; } }', '$v: 600; @each $v in 650 { .in { color: red; } } .after { font-weight: $v; }', '$p: margin; @each $p in font-weight, color { .l { #{$p}: 650; } }', '$q: margin; @each $q in font-weight { .in { color: red; } } .w { #{$q}: 650; }', // Destructuring: a map's keys and values, a list's positions. '@each $name, $w in (650: 400, 750: 600) { .k-#{$name} { font-weight: $w; } }', '@each $a, $w in (x 400, y 650) { .p { font-weight: $w; } }', '@each $prop, $value in (font-weight: 750, color: red) { .q { #{$prop}: $value; } }', '@each $key, $w in ((a b): 400, (c d): 650) { .pk { font-weight: $w; } }', '@each $k, $w in ((x: 650): 400, (y: 1): 600) { .nk { font-weight: $w; } }', // `to` stops before its end, so this loop never runs. '@for $w from 650 to 650 { .e { font-weight: $w; } }', // A space-separated list is one variable's list too. '@each $w in 400 650 600 { .s { font-weight: $w; } }', // A quoted element is one, its spaces, commas and colons included. '@each $label, $w in ("wide label" 650,) { .ql { font-weight: $w; } }', '@each $label, $w in ("a, b" 600, "c" 750) { .qc { font-weight: $w; } }', '@each $k, $w in ("a: b": 650, c: 600) { .qk { font-weight: $w; } }', '@each $l, $w in ("a\\", b" 600, "c" 750) { .qe { font-weight: $w; } }', ].join('\n'); assert.deepEqual(offScale('libs/l3/a.scss', source).sort(), [ '1 $w: 650', '12 #{$p}: 650', '15 $w: 650', '16 $value: 750', '16 font-weight: 750', '17 $w: 650', '20 $w: 650', '21 $w: 650', '22 $w: 750', '23 $w: 650', '24 $w: 750', '3 $w: 650', '4 $weights: 750', '6 $w: from 400 through 402', '9 $w: from 650 through 650', ]); }); test('treats comment markers inside strings as text', () => { const stylesheet = [ '.x { content: "//"; font-weight: 650; }', '.y { background: url(//cdn.test/a.png); font-weight: 750; }', '.z::after { content: "font-weight: 650"; }', ].join('\n'); const component = `styles: ['.x { content: "//"; font-weight: 650; }'],`; assert.deepEqual(offScale('libs/a.scss', stylesheet), [ '1 font-weight: 650', '2 font-weight: 750', ]); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 font-weight: 650', ]); }); test('keeps a quoted family inside the font shorthand', () => { const stylesheet = ".a { font: 650 12px 'DM Sans', sans-serif; }"; const component = `styles: ['.a { font: 650 12px "DM Sans"; }'],`; const template = [ "

Don't wrap

", '

x

', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', stylesheet), ['1 font: 650']); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 font: 650', ]); assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ '2 font: 750', ]); }); test('reads only the tokens before the shorthand size as its weight', () => { const valid = [ '.a { font: 400 16px/1.5 "DM Sans"; }', '.b { font: 400 16px/1.5 Helvetica Neue; }', ".c { font: italic 600 1rem/1.25 'Open Sans', sans-serif; }", '.d { font: 500 small/1.4 Inter; }', '.e { font: 700 calc(1rem + 2px)/1.2 Inter; }', '.f { font: 600 16px / 1.5 Helvetica Neue; }', ].join('\n'); const heavy = [ '.a { font: 650 16px/1.5 "DM Sans"; }', '.b { font: small-caps 750 12px Helvetica Neue; }', '.c { font: 520 var(--size) / 1.5 "DM Sans"; }', ].join('\n'); // Split at its space, the family would pass the size as a weight. const zeroSize = ':root { --size: 0; } .z { font: var(--size) "DM Sans"; }'; assert.deepEqual(offScale('libs/a.scss', valid), []); assert.deepEqual(offScale('libs/z.scss', zeroSize), []); assert.deepEqual(offScale('libs/b.scss', heavy), [ '1 font: 650', '2 font: 750', '3 font: 520', ]); }); test('follows a shorthand that is one variable, fallback included', () => { const source = [ ':root { --body-font: 650 1rem sans-serif; }', '$heading-font: 750 1.2rem sans-serif;', '.a { font: var(--body-font); }', '.b { font: var(--caption-font, 520 0.8rem sans-serif); }', '.c { font: $heading-font; }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), [ '4 font: 520', '2 $heading-font: 750', '1 --body-font: 650', ]); }); test('matches CSS names, keywords and functions in any case', () => { const source = [ '.a { font-weight: BOLDER; }', '.b { font: LiGhTeR 1rem sans-serif; }', '.c { FONT-WEIGHT: 650; }', ':root { --title: 750; }', '.d { font-weight: VAR(--title); }', '.e { FONT: 650 12px sans-serif; }', '.f { FONT: 400 16px/1.5 Inter; }', ].join('\n'); const findings = findOffScaleWeights('libs/a.scss', source).findings; assert.deepEqual( findings.map(({ line, name, value }) => `${line} ${name}: ${value}`), [ '1 font-weight: BOLDER', '3 FONT-WEIGHT: 650', '2 font: LiGhTeR', '6 FONT: 650', '4 --title: 750', ] ); assert.match(describeFinding(findings[0]), /relative to the parent/); }); test('reads numbers and variables inside interpolation', () => { const stylesheet = [ '$w: 750;', '.a { font-weight: #{650}; }', '.b { font-weight: #{$w}; }', '.c { font-weight: #{600}; }', ].join('\n'); const component = [ 'styles: [`', ' .a { font-weight: ${650}; }', ' .b { font-weight: ${700}; }', ' .c { font-weight: ${this.weight()}; }', '`],', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', stylesheet), [ '2 font-weight: 650', '1 $w: 750', ]); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '2 font-weight: 650', ]); }); test('reports a computed weight whole, since Sass compiles it', () => { const computed = [ '.a { font-weight: 400 + 500; }', '.b { font-weight: 400+200; }', '.c { font-weight: $base * 1.5; }', '.d { font-weight: math.div(1300, 2); }', '.e { font-weight: calc(600 + 50); }', '.f { font-weight: map.get($weights, title); }', '.g { font-weight: #{400 + 300}; }', '.h { font-weight: var(--x, 400 + 300); }', '.i { font: 400 + 200 12px sans-serif; }', '.j { font-weight: -(-650); }', '.k { font-weight: -650; }', '.l { font-weight: +(650); }', ].join('\n'); const written = [ '.a { font-weight: +700; }', '.b { font-weight: var(--title-weight, 600); }', '.c { font-weight: $font-weight; }', '.d { font-weight: 600 !important; }', '.e { font-weight: #{$font-weight}; }', ].join('\n'); const findings = findOffScaleWeights('libs/a.scss', computed).findings; assert.deepEqual( findings.map(({ line, value }) => `${line} ${value}`), [ '1 400 + 500', '2 400+200', '3 $base * 1.5', '4 math.div(1300, 2)', '5 calc(600 + 50)', '6 map.get($weights, title)', '7 #{400 + 300}', '8 var(--x, 400 + 300)', '10 -(-650)', '11 -650', '12 +(650)', '9 400 + 200', ] ); assert.match(describeFinding(findings[0]), /is computed/); assert.deepEqual(offScale('libs/b.scss', written), []); }); test('treats `-` and `_` in Sass names alike, not in custom properties', () => { const source = [ '$heavy_value: 650;', '$light-value: 750;', ':root { --a_b: 650; }', '.x { font-weight: $heavy-value; }', '.y { font-weight: $light_value; }', '.z { font-weight: var(--a-b); }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), [ '2 $light-value: 750', '1 $heavy_value: 650', ]); }); test('follows a variable for any stretch of the font shorthand', () => { const source = [ '$a-font: 650 1rem sans-serif;', ':root { --b-font: 750 1rem sans-serif; }', '$c-font: 520 1rem sans-serif;', '$d-weight-and-size: 450 12px;', '.a { font: italic $a-font; }', '.b { font: italic var(--b-font); }', '.c { font: italic #{$c-font}; }', '.d { font: italic $d-weight-and-size Inter; }', ].join('\n'); const sizeAndFamily = [ ':root { --size: 0; }', "$family: 'DM Sans';", '.e { font: 600 var(--size) $family; }', '.f { font: italic 600 var(--size) Helvetica Neue; }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source).sort(), [ '1 $a-font: 650', '2 --b-font: 750', '3 $c-font: 520', '4 $d-weight-and-size: 450', ]); assert.deepEqual(offScale('libs/b.scss', sizeAndFamily), []); }); test('terminates on cyclic variables', { timeout: 5000 }, () => { const source = [ ':root { --body: var(--body) sans-serif; }', '$a: $b 1rem;', '$b: $a sans-serif;', '.x { font: var(--body); }', '.y { font: italic $a Inter; }', '.z { font-weight: var(--body); }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), []); }); test('reports arithmetic in code weights, not calls or conditions', () => { const template = [ '

a

', '

b

', ].join('\n'); const component = [ 'element.style.fontWeight = 600 + 50;', "element.style.setProperty('font-weight', String(base + 100));", 'element.style.fontWeight = this.weight();', ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ '1 [style.font-weight]: 600 + 50', ]); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 .style.fontWeight: 600 + 50', "2 setProperty('font-weight': String(base + 100)", ]); }); test('resolves Sass variables within their module scope', () => { const scan = (file, source) => scanWeights(file, source); const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); // Same name, unrelated files: the spacing value never feeds the weight. const own = scan( 'libs/a/a.scss', '$local: 600; .a { font-weight: $local; }' ); const other = scan('libs/b/b.scss', '$local: 650; .b { margin: $local; }'); assert.deepEqual(report([own, other]), []); // A loaded module, through a namespace. const tokens = scan('libs/c/_tokens.scss', '$title: 650;'); const user = scan( 'libs/c/c.scss', "@use './tokens' as t; .c { font-weight: t.$title; }" ); assert.deepEqual(report([tokens, user]), ['libs/c/_tokens.scss:1 650']); // A mixin argument passed by the file that loads the mixin. const mixin = scan( 'libs/d/_heading.scss', '@mixin heading($w: 500) { font-weight: $w; }' ); const caller = scan( 'libs/d/d.scss', "@use './heading'; .d { @include heading.heading($w: 750); }" ); assert.deepEqual(report([mixin, caller]), ['libs/d/d.scss:1 750']); // Two files that only share a partial are not connected. const shared = scan('libs/e/_shared.scss', '$gap: 4px;'); const reader = scan( 'libs/e/x.scss', "@use './shared'; .x { font-weight: $v; }" ); const sibling = scan( 'libs/e/y.scss', "@use './shared'; $v: 650; .y { margin: $v; }" ); assert.deepEqual(report([shared, reader, sibling]), []); }); test('reads runtime writes to the end of the statement', () => { const component = [ 'element.style.fontWeight =', ' 650;', 'element.style.fontWeight = 600 +', ' 50;', 'element.style.fontWeight = 600', ' + 50;', 'element.style.setProperty(', " 'font-weight',", ' 750', ');', 'element.style.fontWeight += 50;', "if (element.style.fontWeight === '650') {}", "if (element.style.fontWeight !== '650') {}", '[style.font-weight]="25 ** 2"', 'element.style.fontWeight = 2 ** exponent;', ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.ts', component).sort(), [ '1 .style.fontWeight: 650', '11 .style.fontWeight: += 50', '14 [style.font-weight]: 25 ** 2', '15 .style.fontWeight: 2 ** exponent', '3 .style.fontWeight: 600 +\n 50', '5 .style.fontWeight: 600\n + 50', "7 setProperty('font-weight': 750", ] ); }); test('follows bare Sass loads and forwards', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const tokens = scanWeights('libs/c/_tokens.scss', '$heavy: 650;'); const user = scanWeights( 'libs/c/c.scss', "@use 'tokens' as t; .c { font-weight: t.$heavy; }" ); const index = scanWeights('libs/s/_index.scss', "@forward 'panel-header';"); const header = scanWeights('libs/s/_panel-header.scss', '$title: 750;'); const consumer = scanWeights( 'libs/x/x.scss', "@use '../s' as s; .x { font-weight: s.$title; }" ); assert.deepEqual(report([tokens, user]), ['libs/c/_tokens.scss:1 650']); assert.deepEqual(report([index, header, consumer]), [ 'libs/s/_panel-header.scss:1 750', ]); }); test('sees only what a loader passes into a loaded module', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); // `@use` never injects the loader's own variables. const heading = scanWeights( 'libs/h/_heading.scss', '.h { font-weight: $local; }' ); const caller = scanWeights( 'libs/h/h.scss', "@use './heading'; $local: 650; .x { margin: $local; }" ); // A `with (…)` configuration does pass one in. const config = scanWeights( 'libs/f/_config.scss', '$w: 500 !default; .f { font-weight: $w; }' ); const configured = scanWeights( 'libs/f/f.scss', "@use './config' with ($w: 650);" ); // Legacy `@import` is textual, so the importer's declarations count. const part = scanWeights('libs/g/_part.scss', '.g { font-weight: $v; }'); const importer = scanWeights('libs/g/g.scss', "$v: 750; @import 'part';"); // An interpolated value is still the configuration. const heavy = scanWeights( 'libs/f/_heavy.scss', '$w: 650 !default; .f { font-weight: $w; }' ); const interpolated = (value) => scanWeights('libs/f/i.scss', `@use './heavy' with ($w: #{${value}});`); assert.deepEqual(report([heading, caller]), []); assert.deepEqual(report([config, configured]), ['libs/f/f.scss:1 650']); assert.deepEqual(report([heavy, interpolated(600)]), []); assert.deepEqual(report([heavy, interpolated(750)]), [ 'libs/f/i.scss:1 750', ]); assert.deepEqual(report([part, importer]), ['libs/g/g.scss:1 750']); }); test('resolves a module member only through its namespace', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const a = scanWeights( 'libs/n/_a.scss', '$heavy: 600; $font: 600 1rem sans-serif;' ); const b = scanWeights( 'libs/n/_b.scss', '$heavy: 650; $font: 750 1rem sans-serif;' ); const uses = (body) => scanWeights('libs/n/n.scss', `@use 'a'; @use 'b'; ${body}`); assert.deepEqual(report([a, b, uses('.x { font-weight: a.$heavy; }')]), []); assert.deepEqual(report([a, b, uses('.x { font-weight: b.$heavy; }')]), [ 'libs/n/_b.scss:1 650', ]); assert.deepEqual(report([a, b, uses('.y { font: italic b.$font; }')]), [ 'libs/n/_b.scss:1 750', ]); const tokens = scanWeights('libs/m/_tokens.scss', '$heavy: 750;'); const star = scanWeights( 'libs/m/star.scss', "@use 'tokens' as *; .x { font-weight: $heavy; }" ); // A namespaced `@use` adds nothing unqualified (Sass would not compile). const plain = scanWeights( 'libs/m/plain.scss', "@use 'tokens'; .x { font-weight: $heavy; }" ); assert.deepEqual(report([tokens, star]), ['libs/m/_tokens.scss:1 750']); assert.deepEqual(report([tokens, plain]), []); }); test('checks font-weight presentation attributes', () => { const template = [ 'a', "b", 'c', '

d

', 'e', 'f', '

font-weight=750 and font-weight="650" as text

', 'g', '

a < b

a < b font-weight=650

', ].join('\n'); const component = [ `template: 'x',`, 'template: " y\\" font-weight=\\"450\\">"', ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ '1 font-weight: 650', '5 font-weight: 750', '8 font-weight: 520', '3 [attr.font-weight]: 600 + 50', '4 [style.font-weight]: 650', ]); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 font-weight: 750', '2 font-weight: 450', ]); // Server-side artwork in system Arial is not app UI. assert.equal( isScannedFile( 'apps/xtream-mock-server/src/app/generators/marketing.generator.ts' ), false ); assert.equal( isScannedFile( 'libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.ts' ), false ); }); test('counts a `with (…)` configuration of a namespaced module', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const tokens = scanWeights( 'libs/w/_tokens.scss', '$heavy: 500 !default; $heavy-x: 500 !default;' ); // Sass names match across `-`/`_`, configuration included. const aliased = scanWeights( 'libs/w/k.scss', [ "@use './tokens' with ($heavy_x: 750);", '.k { font-weight: tokens.$heavy-x; }', ].join('\n') ); const configured = scanWeights( 'libs/w/w.scss', [ "@use './tokens' with ($heavy: 650);", '.x { font-weight: tokens.$heavy; }', ].join('\n') ); // A mixin argument of the same name is not a configuration. const unrelated = scanWeights( 'libs/w/u.scss', [ "@use './tokens';", '.x { @include m($heavy: 650); font-weight: tokens.$heavy; }', ].join('\n') ); // Configured through a forwarding module. const index = scanWeights( 'libs/v/_index.scss', "@forward 'tokens' with ($light: 450 !default);" ); const forwarded = scanWeights( 'libs/v/_tokens.scss', '$light: 400 !default;' ); const user = scanWeights( 'libs/v/v.scss', "@use '.' as v; .y { font-weight: v.$light; }" ); assert.deepEqual(report([tokens, configured]), ['libs/w/w.scss:1 650']); assert.deepEqual(report([tokens, unrelated]), []); assert.deepEqual(report([tokens, aliased]), ['libs/w/k.scss:1 750']); assert.deepEqual(report([index, forwarded, user]), [ 'libs/v/_index.scss:1 450', ]); }); test('follows custom properties set from code', () => { const component = [ "host: { '[style.--title-weight]': '650' },", "element.style.setProperty('--card-weight', '750');", "element.style.setProperty('--body-font', '520 1rem sans-serif');", '

', "element.style.setProperty('--gap', '12');", // A string is CSS text: the family's number is not a weight. "element.style.setProperty('--quiet-font', '600 1rem \"Font 900\"');", ].join('\n'); const stylesheet = [ '.a { font-weight: var(--title); }', '.b { font: var(--body-font); }', '.c { font: var(--quiet-font); }', ].join('\n'); const scans = [ scanWeights('apps/web/src/a.component.ts', component), scanWeights('libs/a.scss', stylesheet), ]; assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 [style.--title-weight]: 650', "2 setProperty('--card-weight': 750", ]); assert.deepEqual( findIndirectWeights(scans) .map(({ file, line, value }) => `${file}:${line} ${value}`) .sort(), [ 'apps/web/src/a.component.ts:3 520', 'apps/web/src/a.component.ts:4 450', ] ); }); test('reports signed and unary arithmetic in code weights', () => { const component = [ 'element.style.fontWeight = 600 - -50;', '[style.font-weight]="600 - -50"', 'element.style.fontWeight = -(-650);', 'element.style.fontWeight = active ? -650 : 400;', 'element.style.fontWeight = +700;', 'element.style.fontWeight = 600 + -offset;', 'element.style.fontWeight = 600 + +50;', 'element.style.fontWeight = offset + +600;', ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.ts', component).sort(), [ '1 .style.fontWeight: 600 - -50', '2 [style.font-weight]: 600 - -50', '3 .style.fontWeight: -(-650)', '4 .style.fontWeight: -650', '6 .style.fontWeight: 600 + -offset', '7 .style.fontWeight: 600 + +50', '8 .style.fontWeight: offset + +600', ] ); }); test('reads non-decimal JavaScript numbers as the weight they make', () => { const component = [ 'element.style.fontWeight = 0x28a;', "renderer.setStyle(el, 'font-weight', 0o1212);", 'element.style.fontWeight = 0b1010001010;', 'element.style.fontWeight = 6_50;', 'element.style.fontWeight = 650n;', 'element.style.fontWeight = wide ? 0X28A : 400;', 'element.style.fontWeight = 0x258 + 50;', 'element.style.fontWeight = 0x258;', 'element.style.fontWeight = 6_00;', ].join('\n'); assert.deepEqual( offScale('apps/web/src/a.component.ts', component).sort(), [ '1 .style.fontWeight: 650', "2 setStyle(el,'font-weight': 650", '3 .style.fontWeight: 650', '4 .style.fontWeight: 650', '5 .style.fontWeight: 650', '6 .style.fontWeight: 650', '7 .style.fontWeight: 600 + 50', ] ); }); test('reads only the values a code weight can take', () => { const component = [ 'element.style.fontWeight = viewportWidth >= 768 ? 700 : 600;', '[style.font-weight]="wide && width > 1024 ? 700 : 600"', 'element.style.fontWeight = compact ? 600 : wide ? 650 : 700;', 'element.style.fontWeight = (width >= 768 ? 750 : 600);', 'element.style.fontWeight = item?.weight ?? 650;', 'element.style.fontWeight = ready && 650;', 'element.style.fontWeight = width > 1024;', 'element.style.fontWeight = wide ? 600 + 50 : 600;', "element.style.fontWeight = wide ? '650' : '600';", 'element.style.fontWeight = 1300 >> 1;', 'element.style.fontWeight = (width > 1024 && 650) || 700;', 'element.style.fontWeight = a ? w > 768 ? 650 : 700 : 600;', 'element.style.fontWeight = item?.width > 768 ? 650 : 600;', 'element.style.fontWeight = flags & 1024 ? custom ?? 600 : 700;', ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '3 .style.fontWeight: 650', '4 .style.fontWeight: 750', '5 .style.fontWeight: 650', '6 .style.fontWeight: 650', '8 .style.fontWeight: 600 + 50', '9 .style.fontWeight: 650', '10 .style.fontWeight: 1300', '11 .style.fontWeight: 650', '12 .style.fontWeight: 650', '13 .style.fontWeight: 650', ]); }); test('checks setProperty with a template-literal name', () => { const component = [ "element.style.setProperty(`font-weight`, '650');", "element.style.setProperty(`--title-weight`, '750');", "element.style.setProperty(`font-weight`, '600');", ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '1 setProperty(`font-weight`: 650', '2 setProperty(`--title-weight`: 750', ]); }); test('checks indexed style writes', () => { const component = [ "element.style['fontWeight'] = 650;", 'element.style["font-weight"] = 750;', 'element.style[`fontWeight`] += 50;', "if (element.style['fontWeight'] === 650) {}", "element.style['fontWeight'] = 600;", "element.style.fontWeight ||= '650';", "element.style['fontWeight'] ??= 750;", "element.style.fontWeight &&= '600';", "element.style.font = '650 16px sans-serif';", "element.style['font'] = '600 12px x';", "element.style.fontFamily = 'x 650';", "element.style.font = 'italic 12px/200 x';", 'element.style.font = `600 ${size}px/200 Roboto`;', 'element.style.font = `650 ${size}px Roboto`;', ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ "1 .style['fontWeight']: 650", '2 .style["font-weight"]: 750', '3 .style[`fontWeight`]: += 50', '6 .style.fontWeight: 650', "7 .style['fontWeight']: 750", '9 .style.font: 650', '14 .style.font: 650', ]); }); test('checks HostBinding style weights', () => { const component = [ "@HostBinding('style.fontWeight') weight = 650;", '@HostBinding("style.font-weight") get heavy(): number { return this.on ? 700 : 750; }', "@HostBinding('style.fontWeight') readonly ok = 600;", "@HostBinding('attr.font-weight') svg = '650';", "@HostBinding('class.bold') bold = 650;", "@HostBinding('attr.--title-weight') odd = '650';", "@HostBinding('style.fontWeight') get branchy() { if (this.on) { return 600; } return 650; }", "@HostBinding('style.fontWeight') get quoted() { const s = 'return 650'; return 600; }", "@HostBinding('style.fontWeight') get nested() { const f = () => { return 900; }; return 600; }", "@HostBinding('style.fontWeight') get named() { function pick() { return 900; } return 600; }", "@HostBinding('style.font') f = '650 12px x';", "@HostBinding('style.fontWeight') get inIf() { if (this.on) { return 750; } return 600; }", "@HostBinding('style.fontWeight') get typed() { function pick(): number { return 900; } return 600; }", "@HostBinding('style.fontWeight') get shaped() { function pick(): { w: number } { return { w: 900 }; } return 600; }", "@HostBinding('style.fontWeight') get later() { function pick(): Promise<{ w: number }> { return null; } return 650; }", "@HostBinding('style.fontWeight') get arrowType() { function pick(): () => number { return () => 900; } return 600; }", "@HostBinding('style.fontWeight') get bare() { const t = a ? f(b) : c; { return 750; } return 600; }", ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ "1 @HostBinding('style.fontWeight'): 650", '2 @HostBinding("style.font-weight"): 750', "4 @HostBinding('attr.font-weight'): 650", "7 @HostBinding('style.fontWeight'): 650", "11 @HostBinding('style.font'): 650", "12 @HostBinding('style.fontWeight'): 750", "15 @HostBinding('style.fontWeight'): 650", "17 @HostBinding('style.fontWeight'): 750", ]); }); test('checks Renderer2 setStyle weights', () => { const component = [ "this.renderer.setStyle(this.host.nativeElement, 'fontWeight', 650);", "renderer.setStyle(el(), 'font-weight', '750', RendererStyleFlags2.DashCase);", "renderer.setStyle(el, 'fontWeight', 600);", "renderer.setStyle(el, 'color', '650');", "renderer.setStyle(wrap(getEl(a, b)), 'fontWeight', 650);", "node.setAttributeNS(ns(), 'font-weight', '650');", 'renderer.setStyle(el, `font-${kind}`, 650);', "node.setAttribute('--title-weight', '650');", "node.setAttributeNS('http://www.w3.org/2000/svg', 'font-weight', '650');", "renderer.setStyle(el, 'font', '650 16px sans-serif');", "el.style.setProperty('font', 'italic 12px/200 x');", '[style.font]="\'650 12px x\'"', "el.style.setProperty('font', '750 12px x');", ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '12 [style.font]: 650', "1 setStyle(this.host.nativeElement,'fontWeight': 650", "2 setStyle(el(),'font-weight': 750", "5 setStyle(wrap(getEl(a,b)),'fontWeight': 650", "6 setAttributeNS(ns(),'font-weight': 650", "9 setAttributeNS('http://www.w3.org/2000/svg','font-weight': 650", "10 setStyle(el,'font': 650", "13 setProperty('font': 750", ]); }); test('checks font-weight attributes set at runtime', () => { const component = [ "node.setAttribute('font-weight', '650');", 'node.setAttributeNS(null, "font-weight", 750);', "node.setAttribute('font-weight', '600');", "node.setAttribute('aria-label', '650');", ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ "1 setAttribute('font-weight': 650", '2 setAttributeNS(null,"font-weight": 750', ]); }); test('counts only the configuration of the `@use` a member comes from', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const a = scanWeights( 'libs/q/_alpha-palette.scss', '$heavy: 500 !default;' ); const b = scanWeights('libs/q/_beta-palette.scss', '$heavy: 500 !default;'); const user = (reference) => scanWeights( 'libs/q/q.scss', [ "/* Two palettes */ @use 'alpha-palette' as a with ($heavy: 600);", "@use 'beta-palette' as b with ($heavy: 650); // heavy", `.x { @include m($heavy: 750); font-weight: ${reference}; }`, ].join('\n') ); assert.deepEqual(report([a, b, user('a.$heavy')]), []); assert.deepEqual(report([a, b, user('b.$heavy')]), ['libs/q/q.scss:2 650']); // An earlier `@use`'s configuration does not configure a later one. const reversed = scanWeights( 'libs/q/r.scss', "@use 'alpha-palette' as a with ($heavy: 650); @use 'beta-palette' as b with ($heavy: 600); .x { font-weight: b.$heavy; }" ); assert.deepEqual(report([a, b, reversed]), []); }); test('resolves Sass variables within their block', () => { const local = [ '.spacing { $local: 650; margin: $local; }', '.title { $local: 600; font-weight: $local; }', ].join('\n'); const nested = '.a { $w: 650; .b { font-weight: $w; } }'; const global = '.a { $w: 750 !global; } .b { font-weight: $w; }'; const argument = [ '@mixin heading($w: 500) { font-weight: $w; }', '.x { @include heading($w: 650); }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', local), []); // Each block resolves its own `$w`, whichever is checked first. const twoBlocks = [ '.b { $w: 650; font-weight: $w; }', '.a { $w: 600; font-weight: $w; }', ].join('\n'); assert.deepEqual(offScale('libs/f.scss', twoBlocks), ['1 $w: 650']); assert.deepEqual(offScale('libs/b.scss', nested), ['1 $w: 650']); assert.deepEqual(offScale('libs/c.scss', global), ['1 $w: 750']); assert.deepEqual(offScale('libs/d.scss', argument), ['2 $w: 650']); // A chain continues from where the intermediate variable is defined. const chain = '.a { $base: 650; $w: $base !global; } .b { font-weight: $w; }'; assert.deepEqual(offScale('libs/e.scss', chain), ['1 $base: 650']); // A block-local declaration is not a module member. const tokens = scanWeights( 'libs/t/_tokens.scss', '.x { $heavy: 650; margin: 0; padding: 0; color: red; }' ); const user = scanWeights( 'libs/t/t.scss', "@use 'tokens' as t; .y { font-weight: t.$heavy; }" ); assert.deepEqual(findIndirectWeights([tokens, user]), []); }); test('runs a `!global` assignment in text order', () => { const read = '.x { font-weight: $w; }'; for (const setter of [ '.setter { $w: 600 !global; }', '.a { .b { $w: 600 !global; } }', ]) { assert.deepEqual( offScale('libs/g4/a.scss', `$w: 650; ${setter} ${read}`), [] ); } // Under flow control or in a mixin body it may not run first. for (const setter of [ '@if $flag { .s { $w: 600 !global; } }', '@mixin set { $w: 600 !global; }', ]) { assert.deepEqual( offScale('libs/g4/a.scss', `$w: 650; ${setter} ${read}`), ['1 $w: 650'] ); } assert.deepEqual( offScale('libs/g4/a.scss', `.y { $w: 650 !global; } ${read}`), ['1 $w: 650'] ); }); test('resolves the declaration in effect where a Sass name is read', () => { const cases = { // An inner declaration shadows the outer one. shadowed: '$local: 650; .title { $local: 600; font-weight: $local; }', // A reassignment after the reference does not reach it. later: '.t { $w: 600; font-weight: $w; $w: 650; }', // The last unconditional assignment wins. reassigned: '$w: 650; $w: 600; .a { font-weight: $w; }', heavier: '$w: 600; $w: 650; .a { font-weight: $w; }', // Flow control assigns the enclosing variable, conditionally. ifBlock: '.r { $w: 600; @if $c { $w: 650; } font-weight: $w; }', // A conditional assignment does not hide the value before it. ifKeepsOuter: '.r { $w: 650; @if $c { $w: 600; } font-weight: $w; }', elseBlock: '.r { $w: 600; @if $c { $w: 500; } @else { $w: 750; } font-weight: $w; }', eachBlock: '.r { $w: 600; @each $i in 1, 2 { $w: 700; } font-weight: $w; }', topLevelIf: '$w: 600; @if $dark { $w: 520; } .a { font-weight: $w; }', // A mixin body reads module variables when it is included. mixin: '@mixin m { font-weight: $w; } $w: 650; .x { @include m; }', mixinLocal: '@mixin m { $w: 600; font-weight: $w; } $w: 650;', }; const found = Object.fromEntries( Object.entries(cases).map(([name, source]) => [ name, offScale(`libs/${name}.scss`, source), ]) ); assert.deepEqual(found, { shadowed: [], later: [], reassigned: [], heavier: ['1 $w: 650'], ifBlock: ['1 $w: 650'], ifKeepsOuter: ['1 $w: 650'], elseBlock: ['1 $w: 750'], eachBlock: [], topLevelIf: ['1 $w: 520'], mixin: ['1 $w: 650'], mixinLocal: [], }); // A file whose own declaration settles the value ignores an importer's. const part = scanWeights( 'libs/p/_part.scss', '$v: 600; .g { font-weight: $v; }' ); const importer = scanWeights('libs/p/p.scss', "$v: 750; @import 'part';"); assert.deepEqual(findIndirectWeights([part, importer]), []); }); test('passes an argument only to the callable it is given to', () => { const part = scanWeights( 'libs/k/_part.scss', '@mixin heading($w: 500) { font-weight: $w; }' ); const loader = scanWeights( 'libs/k/k.scss', "@use 'part'; .x { @include unrelated($w: 650); @include part.heading; }" ); const passing = scanWeights( 'libs/k/p.scss', "@use 'part'; .x { @include part.heading($w: 750); }" ); const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); assert.deepEqual(report([part, loader]), []); assert.deepEqual(report([part, passing]), ['libs/k/p.scss:1 750']); const sameFile = [ '@mixin heading($w: 500) { font-weight: $w; }', '.x { @include other($w: 650); @include heading; }', '.y { @include other($v: 650); } $v: 600; .z { font-weight: $v; }', ].join('\n'); assert.deepEqual(offScale('libs/same.scss', sameFile), []); }); test('places imported declarations where the @import sits', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const part = scanWeights('libs/i/_part.scss', '$v: 950;'); const after = scanWeights( 'libs/i/after.scss', "$v: 600; @import 'part'; .a { font-weight: $v; }" ); const before = scanWeights( 'libs/i/before.scss', "@import 'part'; $v: 600; .a { font-weight: $v; }" ); assert.deepEqual(report([part, after]), ['libs/i/_part.scss:1 950']); assert.deepEqual(report([part, before]), []); // A nested import takes effect where the outer `@import` sits. const outer = scanWeights('libs/j/_outer.scss', "@import 'inner';"); const inner = scanWeights('libs/j/_inner.scss', '$v: 950;'); const main = scanWeights( 'libs/j/main.scss', "/* main */ $v: 600; @import 'outer'; .a { font-weight: $v; }" ); assert.deepEqual(report([outer, inner, main]), [ 'libs/j/_inner.scss:1 950', ]); }); test('reads module variables where a mixin is called', () => { // No caller in the file: the module's final value is what callers see. const superseded = '$w: 650; $w: 600; @mixin m { font-weight: $w; }'; // A call between the two assignments reads the first one. const calledEarly = [ '$w: 650; .x { @include m; } $w: 600;', '@mixin m { font-weight: $w; }', ].join('\n'); // Callers elsewhere see the module's final value. const noCaller = '$w: 650; @mixin m { font-weight: $w; }'; // A mixin's own signature is not a call. const signature = '$w: 650; @mixin m($x: 1) { font-weight: $w; } $w: 600;'; assert.deepEqual(offScale('libs/m/superseded.scss', superseded), []); assert.deepEqual(offScale('libs/m/no-caller.scss', noCaller), [ '1 $w: 650', ]); assert.deepEqual(offScale('libs/m/signature.scss', signature), []); assert.deepEqual(offScale('libs/m/early.scss', calledEarly), ['1 $w: 650']); }); test('lets `!default` keep an earlier value', () => { const cases = { kept: '$heavy: 650; $heavy: 600 !default; .x { font-weight: $heavy; }', unset: '$heavy: 600 !default; .x { font-weight: $heavy; }', alone: '$heavy: 650 !default; .x { font-weight: $heavy; }', // A mixin included after both assignments reads the final value. included: '$w: 650; $w: 600; @mixin heading { font-weight: $w; } .title { @include heading; }', // A `!default` after a set value never applies, in any scope. ignored: '$w: 600; $w: 650 !default; .x { font-weight: $w; }', // `null` counts as unset, so a later `!default` applies. afterNull: '$w: null; $w: 650 !default; .x { font-weight: $w; }', resetToNull: '$w: 600; $w: null; $w: 650 !default; .x { font-weight: $w; }', innerIgnored: '$w: 600; .x { $w: 650 !default; font-weight: $w; }', }; const found = Object.fromEntries( Object.entries(cases).map(([name, source]) => [ name, offScale(`libs/d/${name}.scss`, source), ]) ); assert.deepEqual(found, { kept: ['1 $heavy: 650'], unset: [], alone: ['1 $heavy: 650'], included: [], ignored: [], innerIgnored: [], afterNull: ['1 $w: 650'], resetToNull: ['1 $w: 650'], }); // The partial's default yields to the value its importer set first. const part = scanWeights( 'libs/d/_part.scss', '$v: 600 !default; .g { font-weight: $v; }' ); const importer = scanWeights( 'libs/d/importer.scss', "$v: 750; @import 'part';" ); assert.deepEqual( findIndirectWeights([part, importer]).map( ({ file, line, value }) => `${file}:${line} ${value}` ), ['libs/d/importer.scss:1 750'] ); }); test('passes an argument to the module that owns the callable', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const a = scanWeights( 'libs/o/_a.scss', '@mixin heading($w: 1) { margin: $w; }' ); const b = scanWeights( 'libs/o/_b.scss', '@mixin heading($w: 600) { font-weight: $w; }' ); const loader = (body) => scanWeights('libs/o/o.scss', body); assert.deepEqual( report([ a, b, loader( "@use 'a'; @use 'b'; .x { @include a.heading($w: 650); @include b.heading; }" ), ]), [] ); assert.deepEqual( report([a, b, loader("@use 'b'; .x { @include b.heading($w: 750); }")]), ['libs/o/o.scss:1 750'] ); assert.deepEqual( report([ a, b, loader("@use 'b' as *; .x { @include heading($w: 750); }"), ]), ['libs/o/o.scss:1 750'] ); // A bare `heading(` also reaches a mixin in an `@import`ed file. assert.deepEqual( report([ a, b, loader("@import 'b'; .x { @include heading($w: 750); }"), ]), ['libs/o/o.scss:1 750'] ); // A bare `heading(` resolves to `a` (`as *`), not to the namespaced `b`. assert.deepEqual( report([ a, b, loader( "@use 'a' as *; @use 'b'; .x { @include heading($w: 650); @include b.heading; }" ), ]), [] ); }); test('counts the configuration of a module loaded `as *`', () => { const tokens = scanWeights('libs/s2/_tokens.scss', '$w: 500 !default;'); const loader = scanWeights( 'libs/s2/s.scss', "@use 'tokens' as * with ($w: 650); .x { font-weight: $w; }" ); assert.deepEqual( findIndirectWeights([tokens, loader]).map( ({ file, line, value }) => `${file}:${line} ${value}` ), ['libs/s2/s.scss:1 650'] ); }); test('runs a partial again at every @import', { timeout: 5000 }, () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const common = scanWeights('libs/r/_common.scss', '$w: 650;'); const a = scanWeights('libs/r/_a.scss', "@import 'common';"); const b = scanWeights('libs/r/_b.scss', "@import 'common';"); const siblings = scanWeights( 'libs/r/siblings.scss', "@import 'a'; $w: 600; @import 'b'; .x { font-weight: $w; }" ); const repeated = scanWeights( 'libs/r/repeated.scss', "@import 'common'; $w: 600; @import 'common'; .x { font-weight: $w; }" ); const x = scanWeights('libs/c2/_x.scss', "@import 'y';"); const y = scanWeights('libs/c2/_y.scss', "@import 'x'; $w: 600;"); const cyclic = scanWeights( 'libs/c2/main.scss', "@import 'x'; .x { font-weight: $w; }" ); assert.deepEqual(report([common, a, b, siblings]), [ 'libs/r/_common.scss:1 650', ]); assert.deepEqual(report([common, repeated]), ['libs/r/_common.scss:1 650']); assert.deepEqual(report([x, y, cyclic]), []); // Inside an inclusion, assignments keep their text order: the partial's // own `$w: 650` comes after its nested import's `$w: 600`. const nested = scanWeights('libs/r2/_nested.scss', '$w: 600;'); const partial = scanWeights( 'libs/r2/_partial.scss', "@import 'nested'; $w: 650;" ); const reordered = scanWeights( 'libs/r2/main.scss', "@import 'partial'; .x { font-weight: $w; }" ); assert.deepEqual(report([nested, partial, reordered]), [ 'libs/r2/_partial.scss:1 650', ]); }); test('runs a call inside a mixin body where that mixin runs', () => { const deferred = [ '$w: 650; @mixin caller { @include m; } $w: 600;', '@mixin m { font-weight: $w; } .x { @include caller; }', ].join('\n'); const calledEarly = [ '$w: 650; @mixin caller { @include m; } .x { @include caller; }', '$w: 600; @mixin m { font-weight: $w; }', ].join('\n'); assert.deepEqual(offScale('libs/n2/deferred.scss', deferred), []); assert.deepEqual(offScale('libs/n2/early.scss', calledEarly), [ '1 $w: 650', ]); }); test('follows members forwarded under a prefix', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const base = scanWeights('libs/f2/_base.scss', '$heavy: 650;'); const bundle = scanWeights( 'libs/f2/_bundle.scss', "@forward 'base' as prefix-*;" ); const outer = scanWeights( 'libs/f2/_outer.scss', "@forward 'bundle' as o-*;" ); const user = (body) => scanWeights('libs/f2/user.scss', body); assert.deepEqual( report([ base, bundle, user("@use 'bundle'; .x { font-weight: bundle.$prefix-heavy; }"), ]), ['libs/f2/_base.scss:1 650'] ); assert.deepEqual( report([ base, bundle, outer, user("@use 'outer'; .x { font-weight: outer.$o-prefix-heavy; }"), ]), ['libs/f2/_base.scss:1 650'] ); // The forward exposes only the prefixed name. assert.deepEqual( report([ base, bundle, user("@use 'bundle'; .x { font-weight: bundle.$heavy; }"), ]), [] ); assert.deepEqual( report([ base, bundle, user("@use 'bundle' as *; .x { font-weight: $prefix-heavy; }"), ]), ['libs/f2/_base.scss:1 650'] ); // One file brought in under two prefixes reads under either. const other = scanWeights( 'libs/f2/_other.scss', "@forward 'base' as other-*;" ); assert.deepEqual( report([ base, bundle, other, user( "@use 'bundle' as *; @use 'other' as *; .x { font-weight: $prefix-heavy; }" ), ]), ['libs/f2/_base.scss:1 650'] ); }); test('renders an imported rule where the @import sits', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const part = scanWeights( 'libs/g2/_part.scss', '$w: 600 !default; .g { font-weight: $w; }' ); const importer = scanWeights('libs/g2/i.scss', "@import 'part'; $w: 650;"); // A partial's mixin runs when called, after the importer's assignment. const mixin = scanWeights( 'libs/g2/_mix.scss', '@mixin m { font-weight: $w; }' ); const caller = scanWeights( 'libs/g2/c.scss', "@import 'mix'; $w: 650; .x { @include m; }" ); assert.deepEqual(report([part, importer]), []); assert.deepEqual(report([mixin, caller]), ['libs/g2/c.scss:1 650']); }); test('skips a module default its configuration replaces', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const tokens = scanWeights('libs/w2/_tokens.scss', '$w: 650 !default;'); const user = (body) => scanWeights('libs/w2/u.scss', body); assert.deepEqual( report([ tokens, user( "@use 'tokens' with ($w: 600); .x { font-weight: tokens.$w; }" ), ]), [] ); assert.deepEqual( report([ tokens, user("@use 'tokens' as * with ($w: 600); .x { font-weight: $w; }"), ]), [] ); assert.deepEqual( report([tokens, user("@use 'tokens'; .x { font-weight: tokens.$w; }")]), ['libs/w2/_tokens.scss:1 650'] ); // Another compilation loading it unconfigured does not change what // this one reads. assert.deepEqual( report([ tokens, user( "@use 'tokens' with ($w: 600); .x { font-weight: tokens.$w; }" ), scanWeights('libs/w2/other.scss', "@use 'tokens';"), ]), [] ); }); test('reads `-` and `_` in callable names alike', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const own = (body) => report([scanWeights('libs/h2/own.scss', body)]); const base = scanWeights( 'libs/h2/_base.scss', '@mixin heading-style($w: 500) { font-weight: $w; }' ); const mod = scanWeights('libs/h2/_mod.scss', "@forward 'base' as p-*;"); const user = (body) => scanWeights('libs/h2/user.scss', body); assert.deepEqual( own( '@mixin heading_style($w: 500) { font-weight: $w; } .x { @include heading-style($w: 650); }' ), ['libs/h2/own.scss:1 650'] ); // Module variables are read where the mixin is called, by either name. for (const [defined, called] of [ ['a-b', 'a_b'], ['a_b', 'a_b'], ]) { assert.deepEqual( own( `$w: 650; @mixin ${defined} { font-weight: $w; } .x { @include ${called}; } $w: 500;` ), ['libs/h2/own.scss:1 650'] ); } assert.deepEqual( report([ base, user("@use 'base'; .x { @include base.heading_style($w: 650); }"), ]), ['libs/h2/user.scss:1 650'] ); assert.deepEqual( report([ base, mod, user("@use 'mod'; .x { @include mod.p_heading-style($w: 650); }"), ]), ['libs/h2/user.scss:1 650'] ); }); test('applies the show and hide lists of a @forward', { timeout: 5000 }, () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const a = scanWeights('libs/k2/_a.scss', '$w: 650;'); const b = scanWeights('libs/k2/_b.scss', '$w: 600;'); const mod = (body) => scanWeights('libs/k2/_mod.scss', body); const user = (body) => scanWeights('libs/k2/user.scss', body); const reads = user("@use 'mod'; .x { font-weight: mod.$w; }"); const readsPrefixed = user("@use 'mod'; .x { font-weight: mod.$p-w; }"); assert.deepEqual( report([a, b, mod("@forward 'a' hide $w; @forward 'b';"), reads]), [] ); assert.deepEqual( report([a, b, mod("@forward 'a' show $x; @forward 'b';"), reads]), [] ); assert.deepEqual( report([ a, b, mod("@forward 'a' show $w; @forward 'b' hide $w;"), reads, ]), ['libs/k2/_a.scss:1 650'] ); // The lists name members as the `@forward` exposes them. assert.deepEqual( report([a, mod("@forward 'a' as p-* hide $p-w;"), readsPrefixed]), [] ); assert.deepEqual( report([a, mod("@forward 'a' as p-* hide $w;"), readsPrefixed]), ['libs/k2/_a.scss:1 650'] ); // A list further out names the prefixes added below it. const inner = scanWeights('libs/k2/_inner.scss', "@forward 'a' as p-*;"); assert.deepEqual( report([ a, inner, mod("@forward 'inner' as o-* hide $o-p-w;"), user("@use 'mod'; .x { font-weight: mod.$o-p-w; }"), ]), [] ); // A list below a prefix is read under that prefix. const hides = scanWeights( 'libs/k2/_hides.scss', "@forward 'a' as p-* hide $p-w;" ); assert.deepEqual( report([ a, hides, mod("@forward 'hides' as o-*;"), user("@use 'mod'; .x { font-weight: mod.$o-p-w; }"), ]), [] ); // Forwarding cycles end, with lists and with prefixes. for (const [back, forward, read] of [ ["@forward 'mod' hide $x;", "@forward 'loop' show $w;", '$w'], ["@forward 'mod' as l-*;", "@forward 'loop' as m-*;", '$m-w'], ]) { const loop = scanWeights('libs/k2/_loop.scss', `${back} @forward 'a';`); assert.deepEqual( report([ a, loop, mod(forward), user(`@use 'mod'; .x { font-weight: mod.${read}; }`), ]), ['libs/k2/_a.scss:1 650'] ); } // An `@import`ed file brings in what it forwards. assert.deepEqual( report([ a, mod("@forward 'a' as p-*;"), user("@import 'mod'; .x { font-weight: $p-w; }"), ]), ['libs/k2/_a.scss:1 650'] ); }); test('reads a prefixed configuration the way Sass names it', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const tokens = scanWeights('libs/q2/_tokens.scss', '$w: 500 !default;'); const rule = scanWeights( 'libs/q2/_rule.scss', '$w: 500 !default; .r { font-weight: $w; }' ); const mod = (body) => scanWeights('libs/q2/_mod.scss', body); const user = (body) => scanWeights('libs/q2/user.scss', body); // `with (…)` on a prefixed `@forward` names the member unprefixed. assert.deepEqual( report([ tokens, mod("@forward 'tokens' as p-* with ($w: 650);"), user("@use 'mod'; .x { font-weight: mod.$p-w; }"), ]), ['libs/q2/_mod.scss:1 650'] ); // A loader of the forwarding module configures it under the prefix, // even where the `@forward` hides it. assert.deepEqual( report([ rule, mod("@forward 'rule' as p-*;"), user("@use 'mod' with ($p-w: 650);"), ]), ['libs/q2/user.scss:1 650'] ); assert.deepEqual( report([ rule, mod("@forward 'rule' as p-* hide $p-w;"), user("@use 'mod' with ($p-w: 650);"), ]), ['libs/q2/user.scss:1 650'] ); const outer = scanWeights('libs/q2/_outer.scss', "@forward 'mod' as o-*;"); assert.deepEqual( report([ rule, mod("@forward 'rule' as p-*;"), outer, user("@use 'outer' with ($o-p-w: 650);"), ]), ['libs/q2/user.scss:1 650'] ); const heavy = scanWeights('libs/q2/_heavy.scss', '$w: 650 !default;'); assert.deepEqual( report([ heavy, mod("@forward 'heavy' as p-*;"), user("@use 'mod' with ($p-w: 600); .x { font-weight: mod.$p-w; }"), ]), [] ); }); test('keeps a module default that a `null` configuration leaves unset', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const tokens = scanWeights('libs/n2/_tokens.scss', '$w: 650 !default;'); const user = (body) => scanWeights('libs/n2/u.scss', body); assert.deepEqual( report([ tokens, user( "@use 'tokens' with ($w: null); .x { font-weight: tokens.$w; }" ), ]), ['libs/n2/_tokens.scss:1 650'] ); assert.deepEqual( report([ tokens, user("@use 'tokens' as * with ($w: null); .x { font-weight: $w; }"), ]), ['libs/n2/_tokens.scss:1 650'] ); }); test('counts a parameter default only where a call leaves it out', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const heading = '@mixin heading($w: 650) { font-weight: $w; }'; const own = (body) => report([scanWeights('libs/d2/own.scss', `${heading} ${body}`)]); assert.deepEqual(own('.x { @include heading($w: 600); }'), []); assert.deepEqual( own( '.x { @include heading($w: 600); } .y { content: "@include heading;"; }' ), [] ); for (const omitted of [ '@include heading;', '@include heading();', '@include heading { color: red; }', ]) { assert.deepEqual( own(`.x { @include heading($w: 600); } .y { ${omitted} }`), ['libs/d2/own.scss:1 650'] ); } // Nothing calls it here, so it may be called from elsewhere. assert.deepEqual(own(''), ['libs/d2/own.scss:1 650']); // A comment between `@mixin` and the name is still a signature. assert.deepEqual( report([ scanWeights( 'libs/d2/note.scss', '@mixin /* a long note on the heading */ heading($w: 650) { font-weight: $w; } .x { @include heading($w: 600); }' ), ]), [] ); assert.deepEqual( report([ scanWeights( 'libs/d2/note.scss', '@mixin /* a long note on the heading */ heading($w: 650) { font-weight: $w; }' ), ]), ['libs/d2/note.scss:1 650'] ); assert.deepEqual( report([ scanWeights( 'libs/d2/note.scss', '$w: 650; @mixin /* a long note on the heading */ a-b() { font-weight: $w; } $w: 500; .x { @include a-b; }' ), ]), [] ); // A module's call reaches its own mixin, not a loader's namesake. const loaded = scanWeights( 'libs/d2/_loaded.scss', '@mixin heading($w: 600) { font-weight: $w; } .x { @include heading($w: 600); }' ); const loader = scanWeights( 'libs/d2/loader.scss', `@use 'loaded'; ${heading}` ); assert.deepEqual(report([loaded, loader]), ['libs/d2/loader.scss:1 650']); const a = scanWeights('libs/d2/_a.scss', heading); const b = scanWeights( 'libs/d2/_b.scss', '@mixin heading($w: 500) { font-weight: $w; }' ); const mod = scanWeights('libs/d2/_mod.scss', "@forward 'a' as p-*;"); const user = (body) => scanWeights('libs/d2/user.scss', body); // A call that leaves the parameter out of another module's mixin does // not use this one's default. assert.deepEqual( report([ a, b, user( "@use 'a'; @use 'b'; .x { @include a.heading($w: 600); @include b.heading; }" ), ]), [] ); assert.deepEqual( report([a, user("@use 'a'; .x { @include a.heading; }")]), ['libs/d2/_a.scss:1 650'] ); assert.deepEqual( report([ a, mod, user("@use 'mod'; .x { @include mod.p-heading($w: 600); }"), ]), [] ); }); test('caps JetBrains Mono rules at the heaviest bundled Mono face', () => { const source = [ ".a { font-family: 'JetBrains Mono', monospace; font-weight: 700; }", '.b { font: 600 12px/1 "JetBrains Mono", monospace; }', ".c { font-family: ui-monospace, 'JetBrains Mono'; font-weight: bold; }", ".d { font-weight: 500; font-family: 'JetBrains Mono'; }", '.e { font-weight: 700; }', ".f { font-family: 'JetBrains Mono'; &:hover, .child { font-weight: 600; } }", ".g { font-family: 'JetBrains Mono'; &--wide { font-weight: 700; } }", ".h { font-family: 'JetBrains Mono'; .x { font-family: Roboto; font-weight: 700; } }", ".i { font-family: 'JetBrains Mono'; @media (min-width: 1px) { font-weight: 600; } }", "@font-face { font-family: 'JetBrains Mono'; font-weight: 700; }", ".j { font-family: 'JetBrains Mono'; font-weight: 650; }", ".k { font-family: 'JetBrains Mono'; --title-weight: 600; }", ".l { font-family: 'JetBrains Mono'; font-family: Roboto; font-weight: 700; }", "@mixin m { font-weight: 700; } .n { font-family: 'JetBrains Mono'; @include m; }", ".o { font-family: 'JetBrains Mono'; @include up { font-weight: 700; } }", ".p { font-family: 'JetBrains Mono'; @mixin local { font-weight: 700; } }", ".q { font-weight: 700; font: 400 12px 'JetBrains Mono'; }", ".r { font-weight: 700 !important; font: 400 12px 'JetBrains Mono'; }", ".s { font: 400 12px 'JetBrains Mono'; font-weight: 700; }", ".t { font-weight: 700; font-weight: 500; font-family: 'JetBrains Mono'; }", ".u { font-weight: 700; font: bogus; font-family: 'JetBrains Mono'; }", ".v { font-weight: 700; font: 12px; font-family: 'JetBrains Mono'; }", ".w { font-weight: 700; font: var(--f); font-family: 'JetBrains Mono'; }", ".x2 { font-weight: 700; font: inherit; font-family: 'JetBrains Mono'; }", ".y2 { font-weight: 700; font: 12px/1.4 sans-serif; font-family: 'JetBrains Mono'; }", ".z2 { font-weight: 700; font: 12px / 1.4; font-family: 'JetBrains Mono'; }", ".z3 { font: 700 12px 1.4; font-family: 'JetBrains Mono'; }", ".z4 { font-family: 'JetBrains Mono'; font: 12px; font-weight: 700; }", ].join('\n'); assert.equal(MONO_WEIGHT_CAP, 500); assert.deepEqual(offScale('libs/m3/a.component.scss', source).sort(), [ '1 font-weight: 700', '11 font-weight: 650', // A mixin's weight lands where it is included. '14 font-weight: 700', '15 font-weight: 700', '18 font-weight: 700', '19 font-weight: 700', '2 font: 600', '21 font-weight: 700', '22 font-weight: 700', '26 font-weight: 700', '28 font-weight: 700', '3 font-weight: bold', '6 font-weight: 600', '9 font-weight: 600', ]); for (const finding of findOffScaleWeights( 'libs/m3/a.component.scss', source ).findings) { assert.match(describeFinding(finding), /JetBrains Mono.*Use 500\.$/); } }); test('follows the JetBrains Mono family only where it is inherited', () => { const mono = "font-family: 'JetBrains Mono'"; const source = [ `.a { ${mono}; + .item { font-weight: 700; } }`, `.b { ${mono}; & ~ .item { font-weight: 700; } }`, `.c { ${mono}; &--wide, &:hover { font-weight: 700; } }`, `.d { ${mono}; :is(.x, .y) { font-weight: 700; } }`, `.e { ${mono}; .f { font-family: inherit; font-weight: 700; } }`, `.g { ${mono}; + .h { font-family: inherit; font-weight: 700; } }`, `.i { ${mono}; .u { font-family: Roboto; font-family: unset; font-weight: 700; } }`, `.j { ${mono}; .k { font-family: Roboto; font-family: inherit; font-weight: 700; } }`, `.l { ${mono}; &--wide:is(.x, .y) { font-weight: 700; } }`, `.m { ${mono} !important; font-family: sans-serif; font-weight: 700; }`, `.n { font-family: Roboto !important; ${mono}; font-weight: 700; }`, `.o { font-family: Roboto !important; ${mono} !important; font-weight: 700; }`, ].join('\n'); assert.deepEqual(offScale('libs/m4/a.scss', source), [ '3 font-weight: 700', '4 font-weight: 700', '5 font-weight: 700', '7 font-weight: 700', '8 font-weight: 700', '10 font-weight: 700', '12 font-weight: 700', ]); }); test('resolves a JetBrains Mono family named through variables', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const rule = (body) => scanWeights('libs/m4/rule.scss', body); const theme = (body) => scanWeights('libs/m4/theme.scss', body); assert.deepEqual( report([ theme( ':root { --face: ui-monospace, "JetBrains Mono", monospace; }' ), rule('.a { font-family: var(--face); font-weight: 600; }'), ]), ['libs/m4/rule.scss:1 600'] ); assert.deepEqual( report([ theme(":root { --face: var(--brand); --brand: 'JetBrains Mono'; }"), rule('.a { font: 700 12px var(--face); }'), ]), ['libs/m4/rule.scss:1 700'] ); assert.deepEqual( report([ theme(':root { --face: Roboto, sans-serif; }'), rule('.a { font-family: var(--face); font-weight: 700; }'), ]), [] ); // A `var()` fallback counts only where the property is never set. const fallback = (family) => rule(`.a { font-family: ${family}; font-weight: 700; }`); const everything = (scans) => [ ...scans.flatMap((scan) => scan.findings.map( ({ file, line, value }) => `${file}:${line} ${value}` ) ), ...report(scans), ]; for (const family of [ "var(--face, 'JetBrains Mono')", "var(--face, var(--y), 'JetBrains Mono')", ]) { assert.deepEqual( everything([theme(':root { --face: Roboto; }'), fallback(family)]), [] ); } for (const family of [ "var(--nowhere, ui-monospace, 'JetBrains Mono', monospace)", "var(--x, var(--y, 'JetBrains Mono'))", ]) { assert.deepEqual(report([fallback(family)]), [ 'libs/m4/rule.scss:1 700', ]); } assert.deepEqual( report([ theme(':root { --y: Roboto; }'), fallback("var(--x, var(--y, 'JetBrains Mono'))"), ]), [] ); assert.deepEqual( report([ theme(":root { --face: var(--brand, 'JetBrains Mono'); }"), fallback('var(--face)'), ]), ['libs/m4/rule.scss:1 700'] ); // A property set to a CSS-wide keyword, or through an unset `var()` // without a fallback, can be unset too. for (const face of ['initial', 'inherit', 'var(--nope)']) { assert.deepEqual( report([ theme(`:root { --face: ${face}; }`), fallback("var(--face, 'JetBrains Mono')"), ]), ['libs/m4/rule.scss:1 700'] ); } assert.deepEqual( report([ theme(':root { --face: var(--nope, Roboto); }'), fallback("var(--face, 'JetBrains Mono')"), ]), [] ); // `inherit` takes what another rule sets; a fallback that is itself // unset leaves the value invalid. assert.deepEqual( report([ theme(':root { --face: Roboto; } .child { --face: inherit; }'), fallback("var(--face, 'JetBrains Mono')"), ]), [] ); assert.deepEqual( report([ theme(':root { --face: var(--missing, var(--also-missing)); }'), fallback("var(--face, 'JetBrains Mono')"), ]), ['libs/m4/rule.scss:1 700'] ); assert.deepEqual( report([ rule( "$mono: 'JetBrains Mono', monospace; .a { font-family: $mono; font-weight: 600; }" ), ]), ['libs/m4/rule.scss:1 600'] ); assert.deepEqual( report([ scanWeights('libs/m4/_type.scss', "$mono: 'JetBrains Mono';"), rule("@use 'type'; .a { font: 600 12px type.$mono; }"), ]), ['libs/m4/rule.scss:1 600'] ); // Its weights through variables are capped too, an off-scale one is // reported once, and a family set from code counts. assert.deepEqual( report([ theme(":root { --face: 'JetBrains Mono'; }"), rule( '$w: 600; .a { font-family: var(--face); font-weight: $w; } .b { font-family: var(--face); font-weight: 650; }' ), ]), ['libs/m4/rule.scss:1 600'] ); assert.deepEqual( report([ scanWeights( 'libs/m4/x.component.ts', "el.style.setProperty('--face', \"'JetBrains Mono'\");" ), rule('.a { font-family: var(--face); font-weight: 700; }'), ]), ['libs/m4/rule.scss:1 700'] ); // A property in a cycle is invalid, so its readers take the fallback; // one read twice in a value is no cycle. const mono = rule( ".a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }" ); for (const cycle of [ ':root { --face: var(--face); }', ':root { --face: var(--other); --other: var(--face); }', ]) { assert.deepEqual( report([theme(cycle), mono]), ['libs/m4/rule.scss:1 700'], cycle ); } assert.deepEqual( report([ theme( ':root { --base: Roboto; --face: var(--base), var(--base); }' ), mono, ]), [] ); // The same selector written again in the file sets it on the same // elements; another selector, a condition or another file does not. const read = ".a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }"; for (const [setter, expected] of [ ['.a { --face: Roboto; }', []], ['.b { --face: Roboto; }', ['libs/m4/rule.scss:1 700']], [ '@media (min-width: 1px) { .a { --face: Roboto; } }', ['libs/m4/rule.scss:1 700'], ], ['.p { .a { --face: Roboto; } }', ['libs/m4/rule.scss:1 700']], ]) { assert.deepEqual(report([rule(`${setter} ${read}`)]), expected, setter); } // Nested alike, or written with other spacing, it is the same selector. for (const same of [ ".p { .a { --face: Roboto; } } .p { .a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; } }", ".p .a { --face: Roboto; } .p .a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }", ]) { assert.deepEqual(report([rule(same)]), [], same); } // A string's spacing is its own, past an escaped quote too: these match // different elements. for (const [setter, reader] of [ ["[title='a b']", "[title='a b']"], ["[title='a \\'b']", "[title='a \\'b']"], ]) { assert.deepEqual( report([ rule( `${setter} { --face: Roboto; } ${reader} { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }` ), ]), ['libs/m4/rule.scss:1 700'], setter ); } assert.deepEqual(report([theme('.a { --face: Roboto; }'), rule(read)]), [ 'libs/m4/rule.scss:1 700', ]); }); test('reads `!important` however it is spaced or cased', () => { const mono = "'JetBrains Mono'"; const report = (file, body) => findOffScaleWeights(file, body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [ `.x { font-family: ${mono} ! important; font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono} !/* why */important; font-weight: 700; }`, ['font-weight: 700'], ], [`.x { font: 700 12px ${mono} ! IMPORTANT; }`, ['font: 700']], // It wins over a later declaration of its property. [ `.x { font-family: ${mono} ! important; font-family: Roboto; font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-weight: 700 ! important; font-family: ${mono}; font-weight: 400; }`, ['font-weight: 700'], ], [ `:root { --face: ${mono} ! important; } :root { --face: Roboto; } .x { font-family: var(--face); font-weight: 700; }`, ['font-weight: 700'], ], // A shorthand keyword with it still parses, so it sets the family. [ `.x { font-family: ${mono}; font: inherit ! important; font-weight: 700; }`, [], ], ]) { assert.deepEqual(report('libs/s2/a.scss', source), expected, source); } assert.deepEqual( report( 'apps/web/src/a.component.html', `
` ), ['font-weight: 700'] ); // A shorthand that is all variables keeps its family from them. assert.deepEqual( findIndirectWeights([ scanWeights( 'libs/s2/theme.scss', `:root { --f: 400 12px ${mono}; }` ), scanWeights( 'libs/s2/b.scss', '.x { font: var(--f) ! important; .y { font-weight: 700; } }' ), ]).map(({ name, value }) => `${name}: ${value}`), ['font-weight: 700'] ); }); test('treats blocks with the same selector as one rule', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s1/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // Its family is the last one in source order, wherever the weight sits, // unless an earlier one is `!important`. for (const [source, expected] of [ [ `.x { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-weight: 700; } .x { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .x { font-family: Roboto; } .x { font-weight: 700; }`, [], ], [ `.x { font-family: ${mono} !important; } .x { font-family: Roboto; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `.p { .x { font-family: ${mono}; } } .p { .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `@media (min-width: 1px) { .x { font-family: ${mono}; } } @media (min-width: 1px) { .x { font-weight: 700; } }`, ['font-weight: 700'], ], // Another selector is another rule. [`.y { font-family: ${mono}; } .x { font-weight: 700; }`, []], // A later weight in the rule replaces an earlier one. [ `.x { font-family: ${mono}; font-weight: 700; } .x { font-weight: 400; }`, [], ], // So does a custom property, unless an `@if` may skip the later one. [ `:root { --face: ${mono}; } :root { --face: Roboto; } .x { font-family: var(--face); font-weight: 700; }`, [], ], [ `:root { --face: Roboto; } :root { --face: ${mono}; } .x { font-family: var(--face); font-weight: 700; }`, ['font-weight: 700'], ], [ `:root { --face: ${mono} !important; } :root { --face: Roboto; } .x { font-family: var(--face); font-weight: 700; }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } @if $a { :root { --face: Roboto; } } .x { font-family: var(--face); font-weight: 700; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } // An interpolated selector reads the literal its variable holds there, // so it is the same rule where the value is and another where it is not. const weight = `font-weight: 700;`; for (const [source, expected] of [ [ `$n: a; .#{$n} { font-family: ${mono}; ${weight} } $n: b; .#{$n} { font-family: Roboto; }`, ['font-weight: 700'], ], [ `$n: a; .#{$n} { font-family: Roboto; ${weight} } $n: b; .#{$n} { font-family: ${mono}; }`, [], ], [ `$n: a; .#{$n} { font-family: ${mono}; ${weight} } .#{$n} { font-weight: 500; }`, [], ], [ `$n: a; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `$n: 'a'; .#{$n} { ${weight} } .a { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `.p { $n: a; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `$n: a; .#{$n} { ${weight} } @include m($n: b); .#{$n} { font-family: ${mono}; }`, ['font-weight: 700'], ], // A string or a mixin's local assignment is not another value. [ `$n: a; .#{$n} { ${weight} } @debug "$n: b"; .#{$n} { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `@mixin m { $n: a; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `$n: a; .#{$n} { --face: Roboto; } .#{$n} { font-family: var(--face, ${mono}); ${weight} }`, [], ], [ `$n: a; .#{$n} { --face: Roboto; } $n: b; .#{$n} { font-family: var(--face, ${mono}); ${weight} }`, ['font-weight: 700'], ], // A value the scan cannot know keeps each block its own rule. [`.#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, []], [ `$n: a; @if $c { $n: b; } .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, [], ], [ `$n: a; .y { $n: b !global; } .#{$n} { ${weight} } .a { font-family: ${mono}; }`, [], ], [ `$n: a !default; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, [], ], [ `.x { $n: a; } .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, [], ], [`$n: t.$v; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, []], ]) { assert.deepEqual(report(source), expected, source); } }); test("prefers a rule's own custom property over an inherited one", () => { const mono = "'JetBrains Mono'"; const read = 'font-family: var(--face); font-weight: 700;'; const report = (body) => findOffScaleWeights('libs/s3/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [`:root { --face: ${mono}; } .x { --face: Roboto; ${read} }`, []], [ `:root { --face: Roboto; } .x { --face: ${mono}; ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } .x { --face: Roboto; } .x { ${read} }`, [], ], [`.p { --face: ${mono}; .x { --face: Roboto; ${read} } }`, []], // Not its own: inherited on purpose, conditional, or another rule. [ `:root { --face: ${mono}; } .x { --face: inherit; ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } .x { @if $a { --face: Roboto; } ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } @media (min-width: 1px) { .x { --face: Roboto; } } .x { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } .y { --face: Roboto; } .x { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } @if $a { .x { --face: Roboto; } } .x { ${read} }`, ['font-weight: 700'], ], // Inside the same condition, it is its own. [ `:root { --face: ${mono}; } .x { @if $a { --face: Roboto; ${read} } }`, [], ], // Sass variables keep Sass scoping: an `@if` may still assign Mono. [ `.x { $f: Roboto; @if $a { $f: ${mono}; } font-family: $f; font-weight: 700; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } // Another file's rule styles other elements (component styles). assert.deepEqual( findIndirectWeights([ scanWeights('libs/s3/theme.scss', `:root { --face: ${mono}; }`), scanWeights('libs/s3/b.scss', '.x { --face: Roboto; }'), scanWeights('libs/s3/a.scss', `.x { ${read} }`), ]).map(({ value }) => value), ['700'] ); // A value set from code (an inline style) can still win. assert.deepEqual( findIndirectWeights([ scanWeights( 'libs/s3/x.component.ts', `el.style.setProperty('--face', "${mono}");` ), scanWeights('libs/s3/a.scss', `.x { --face: Roboto; ${read} }`), ]).map(({ value }) => value), ['700'] ); }); test('prefers the nearest inherited custom property', () => { const mono = "'JetBrains Mono'"; const read = 'font-family: var(--face); font-weight: 700;'; const report = (body) => findOffScaleWeights('libs/s4/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // `*` sets each element, an enclosing rule sits nearer than `body`, // `body` nearer than `:root`, and a registered initial value applies // only where nothing is set. for (const [source, expected] of [ [ `:root { --face: ${mono}; } body { --face: Roboto; } .x { ${read} }`, [], ], [ `:root { --face: Roboto; } body { --face: ${mono}; } .x { ${read} }`, ['font-weight: 700'], ], [`:root { --face: ${mono}; } * { --face: Roboto; } .x { ${read} }`, []], [ `* { --face: ${mono}; } body { --face: Roboto; } .x { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } .p { --face: Roboto; .x { ${read} } }`, [], ], [`.p { --face: ${mono}; .q { --face: Roboto; .x { ${read} } } }`, []], [ `.p { --face: Roboto; .q { --face: ${mono}; .x { ${read} } } }`, ['font-weight: 700'], ], [ `@property --face { syntax: '*'; inherits: true; initial-value: ${mono}; } body { --face: Roboto; } .x { ${read} }`, [], ], // Not settled: inherited on purpose, conditional, or not an // ancestor for sure; and `body` does not reach `:root`. [ `:root { --face: ${mono}; } body { --face: inherit; } .x { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } @media (min-width: 1px) { body { --face: Roboto; } } .x { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } .y { --face: Roboto; } .x { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } :root { ${read} }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } // Across files too; a value set from code can still win. const theme = (body) => scanWeights('libs/s4/theme.scss', body); const consumer = scanWeights('libs/s4/b.scss', `.x { ${read} }`); // A condition in another file is not the reader's, even at the same // place in its text. assert.deepEqual( findIndirectWeights([ theme(`:root { --face: ${mono}; }`), scanWeights( 'libs/s4/d.scss', '@media (min-width: 1px) { body { --face: Roboto; } }' ), scanWeights( 'libs/s4/e.scss', `@media (min-width: 1px) { .x { ${read} } }` ), ]).map(({ value }) => value), ['700'] ); assert.deepEqual( findIndirectWeights([ theme(`:root { --face: ${mono}; } body { --face: Roboto; }`), consumer, ]), [] ); assert.deepEqual( findIndirectWeights([ theme('body { --face: Roboto; }'), scanWeights( 'libs/s4/x.component.ts', `el.style.setProperty('--face', "${mono}");` ), consumer, ]).map(({ value }) => value), ['700'] ); }); test('reads an interpolated family declaration whole', () => { const report = (body) => findOffScaleWeights('libs/s4/c.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [ "$face: 'JetBrains Mono'; .x { font-family: #{$face}; font-weight: 700; }", ['font-weight: 700'], ], [ "$face: 'JetBrains Mono'; .x { font-family: #{$face}, monospace; font-weight: 700; }", ['font-weight: 700'], ], [ "$face: 'JetBrains Mono'; .x { font-family: #{$face}; .y { font-weight: 700; } }", ['font-weight: 700'], ], ['$face: Roboto; .x { font-family: #{$face}; font-weight: 700; }', []], // The list goes on past the interpolation, and past a quoted `;`. [ "$face: 'Fira'; .x { font-family: #{$face}, 'JetBrains Mono'; font-weight: 700; }", ['font-weight: 700'], ], [ ".x { font-family: 'Odd;Name', 'JetBrains Mono'; font-weight: 700; }", ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads a selector list as each of its selectors', () => { const mono = "'JetBrains Mono'"; const read = 'font-family: var(--face); font-weight: 700;'; const report = (body) => findOffScaleWeights('libs/s5/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // A family set for one selector of a list reaches that selector. [ `.x, .y { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .x, .y { font-weight: 700; }`, ['font-weight: 700'], ], [ `.y { font-family: ${mono}; } .x, .y { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a, .b { .x { font-family: ${mono}; } } .a { .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x, .y { font-family: ${mono}; } .x { font-family: Roboto; } .x { font-weight: 700; }`, [], ], [`.x, .y { font-family: ${mono}; } .z { font-weight: 700; }`, []], // A weight stays in effect while it is for any of its selectors. [ `.x, .y { font-weight: 700; } .x { font-weight: 400; } .x, .y { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `.x, .y { font-weight: 700; } .x, .y { font-weight: 400; } .x { font-family: ${mono}; }`, [], ], // A custom property: set for the reader's selectors, replaced for them. [ `:root { --face: ${mono}; } .x, .y { --face: Roboto; } .x { ${read} }`, [], ], [ `:root { --face: ${mono}; } .x { --face: Roboto; } .x, .y { ${read} }`, ['font-weight: 700'], ], [ `.x, .y { --face: ${mono}; } .x { --face: Roboto; } .x { ${read} }`, [], ], [ `.x, .y { --face: ${mono}; } .x { --face: Roboto; } .y { ${read} }`, ['font-weight: 700'], ], // Selectors without a family of their own inherit one from outside; // one named through a variable is resolved later. [ `.p { font-family: ${mono}; .x { font-family: Roboto; } .x, .y { font-weight: 700; } }`, ['font-weight: 700'], ], [ `:root { --f: ${mono}; } .x { font-family: var(--f); } .x, .y { font-weight: 700; }`, ['font-weight: 700'], ], // More than 16 chains keep the block its own rule. [ `.a, .b, .c, .d, .e { .f, .g, .h, .i { font-family: ${mono}; } } .a { .f { font-weight: 700; } }`, [], ], // A comma in a string is no list; `:is(.x, .y)` is one selector // that every `.x` matches. [ `[title="a, b"] { font-family: ${mono}; } [title="a"] { font-weight: 700; }`, [], ], [ `:is(.x, .y) { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads `revert` on a custom property as inheriting', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s5/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // `revert` and `revert-layer` bring back the inherited value; only // `initial` leaves the property unset, for the fallback. for (const [keyword, expected] of [ ['revert', ['font-weight: 700']], ['revert-layer', ['font-weight: 700']], ['unset', ['font-weight: 700']], ['initial', []], ]) { assert.deepEqual( report( `:root { --face: ${mono}; } .x { --face: ${keyword}; font-family: var(--face, Roboto); font-weight: 700; }` ), expected, keyword ); } assert.deepEqual( report( `.p { font-family: ${mono}; .x { font-family: revert; font-weight: 700; } }` ), ['font-weight: 700'] ); }); test("reads a mixin's declarations where it is included", () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s6/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // Its family, at the `@include`, in source order with the rule's own. [ `@mixin mono { font-family: ${mono}; } .x { @include mono; font-weight: 700; }`, ['font-weight: 700'], ], [ `@mixin mono { font-family: ${mono}; } .x { @include mono; font-family: Roboto; font-weight: 700; }`, [], ], [ `@mixin mono { font-family: ${mono}; } .x { font-family: Roboto; @include mono; font-weight: 700; }`, ['font-weight: 700'], ], [ `@mixin mono { font-family: ${mono}; } .x { @include mono; .y { font-weight: 700; } }`, ['font-weight: 700'], ], [ `@mixin m-o { font-family: ${mono}; } .x { @include m_o; font-weight: 700; }`, ['font-weight: 700'], ], [`@mixin mono { font-family: ${mono}; } .x { font-weight: 700; }`, []], // Its weight, against the including rule's family unless it sets one. [ `@mixin heavy { font-weight: 700; } .x { font-family: Roboto; @include heavy; }`, [], ], [ `@mixin heavy { font-family: Roboto; font-weight: 700; } .x { font-family: ${mono}; @include heavy; }`, [], ], // A family named through a variable, resolved later. [ `@mixin heavy { font-weight: 700; } :root { --f: ${mono}; } .x { font-family: var(--f); @include heavy; }`, ['font-weight: 700'], ], // Another module's mixin, or the text in a string, is not this one. [ `@mixin t { font-family: ${mono}; } .x { @include t.other; font-weight: 700; }`, [], ], [ `@mixin mono { font-family: ${mono}; } .x { content: "@include mono"; font-weight: 700; }`, [], ], // A variable from outside may change before the `@include`. [ `$n: a; @mixin m { .#{$n} { font-family: ${mono}; font-weight: 700; } .a { font-weight: 500; } } $n: b; .x { @include m; }`, ['font-weight: 700'], ], [ `$n: a; @mixin m { .#{$n} { font-family: ${mono}; font-weight: 700; } } $n: b; .a { font-weight: 500; } .b { @include m; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads a self-sibling selector as the parent element', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s6/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [ `.x { font-family: ${mono}; & + & { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; & ~ &.on { font-weight: 700; } }`, ['font-weight: 700'], ], [`.x { font-family: ${mono}; & + .y { font-weight: 700; } }`, []], [`.x { font-family: ${mono}; &-y { font-weight: 700; } }`, []], ]) { assert.deepEqual(report(source), expected, source); } }); test('inherits a family from a flat ancestor selector', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s7/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // The nearest top-level rule naming an ancestor of the target gives its // family; a sibling is no ancestor. for (const [source, expected] of [ [ `.parent { font-family: ${mono}; } .parent .child { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent { font-family: ${mono}; } .parent > .child { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a { font-family: ${mono}; } .a>.b { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a { font-family: ${mono}; } .a .b + .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a { font-family: ${mono}; } @media (min-width: 1px) { .a .b { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.a { font-family: Roboto; } .a .b { font-family: ${mono}; } .a .b .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a { font-family: ${mono}; } .a .b { font-family: inherit; } .a .b .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent { font-family: ${mono}; } .parent + .child { font-weight: 700; }`, [], ], [ `.a { font-family: ${mono}; } .a .b { font-family: Roboto; } .a .b .c { font-weight: 700; }`, [], ], [ `.a { font-family: ${mono}; } .a .b { font-family: Roboto; font-weight: 700; }`, [], ], // Spaced one way: `.a>.b` names the same element as `.a > .b`, and // `.a.b` (one element with both classes) is no ancestor of `.a .b`. [ `.a>.b { font-family: ${mono}; } .a > .b .c { font-weight: 700; }`, ['font-weight: 700'], ], [`.a.b { font-family: ${mono}; } .a .b .c { font-weight: 700; }`, []], // In a selector list, any selector whose ancestor renders Mono counts. [ `.p { font-family: Roboto; } .q { font-family: ${mono}; } .p .x, .q .y { font-weight: 700; }`, ['font-weight: 700'], ], [ `:root { --f: ${mono}; } .p { font-family: Roboto; } .q { font-family: var(--f); } .p .x, .q .y { font-weight: 700; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads a non-inheriting registered property and a compound root', () => { const mono = "'JetBrains Mono'"; const read = 'font-family: var(--face); font-weight: 700;'; const registered = (initial, inherits) => `@property --face { syntax: '*'; inherits: ${inherits}; initial-value: ${initial}; }`; const report = (body) => findOffScaleWeights('libs/s7/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // `inherits: false`: no ancestor's value reaches the element. [ `${registered(mono, 'false')} body { --face: Roboto; } .x { ${read} }`, ['font-weight: 700'], ], [ `${registered('Roboto', 'false')} body { --face: ${mono}; } .x { ${read} }`, [], ], [ `${registered('Roboto', 'false')} * { --face: ${mono}; } .x { ${read} }`, ['font-weight: 700'], ], [ `${registered('Roboto', 'false')} .x { --face: ${mono}; ${read} }`, ['font-weight: 700'], ], [ `${registered(mono, 'true')} body { --face: Roboto; } .x { ${read} }`, [], ], // A compound selector on the root element is beyond `body`'s reach, // past brackets, strings, parentheses and escapes in it. [ `:root { --face: ${mono}; } body { --face: Roboto; } html:not(.a .b) { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } html[data-x="a] b"] { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } html.a\\ b { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } html[data-x="a"] .x { ${read} }`, [], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } html.theme { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } :root:not(.x) { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: ${mono}; } body { --face: Roboto; } html .x { ${read} }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads a Sass `@extend` as the extended rule applied to its own', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s8/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // The extended rule's family, where that rule is written. [ `%mono { font-family: ${mono}; } .x { @extend %mono; font-weight: 700; }`, ['font-weight: 700'], ], [ `.m { font-family: ${mono}; } .x { @extend .m; font-weight: 700; }`, ['font-weight: 700'], ], [ `%mono { font-family: ${mono}; } .x { @extend %mono !optional; font-weight: 700; }`, ['font-weight: 700'], ], [ `%mono { font-family: ${mono}; } .x { @extend %mono; .y { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { font-family: Roboto; @extend %mono; font-weight: 700; } %mono { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `%mono { font-family: ${mono}; } .x { font-family: Roboto; @extend %mono; font-weight: 700; }`, [], ], [ `%mono { font-family: ${mono}; } .x { @extend %other; font-weight: 700; }`, [], ], [ `%mono { font-family: ${mono}; } .x { content: "@extend %mono;"; font-weight: 700; }`, [], ], [ `.m { font-family: ${mono}; } .x { @extend .n, .m; font-weight: 700; }`, ['font-weight: 700'], ], // Its weight, against each extending rule's family. [ `%heavy { font-weight: 700; } .x { font-family: ${mono}; @extend %heavy; }`, ['font-weight: 700'], ], [ `%heavy { font-weight: 700; } .x { font-family: Roboto; @extend %heavy; }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads custom properties per selector of a reading list', () => { const mono = "'JetBrains Mono'"; const read = 'font-family: var(--face); font-weight: 700;'; const report = (body) => findOffScaleWeights('libs/s8/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // Each selector of `.x, .y` styles its own elements: an override for // one, or no value of its own there, decides that selector alone. for (const [source, expected] of [ [ `.x, .y { --face: Roboto; } .y { --face: ${mono}; } .x, .y { ${read} }`, ['font-weight: 700'], ], [ `.x, .y { --face: ${mono}; } .y { --face: Roboto; } .x, .y { ${read} }`, ['font-weight: 700'], ], [ `.x, .y { --face: ${mono}; } .x { --face: Roboto; } .y { --face: Roboto; } .x, .y { ${read} }`, [], ], [ `:root { --face: ${mono}; } .x { --face: Roboto; } .x, .y { ${read} }`, ['font-weight: 700'], ], [ `:root { --face: Roboto; } .x { --face: Roboto; } .x, .y { ${read} }`, [], ], [ `:root { --face: ${mono}; } .x, .y { --face: Roboto; } .x, .y { ${read} }`, [], ], // Every selector setting its own leaves no room for another rule's, // and one replaced for a selector is gone there. [ `.z { --face: ${mono}; } .x { --face: Roboto; } .y { --face: Roboto; } .x, .y { ${read} }`, [], ], [ `.x, .z { --face: ${mono}; } .x { --face: Roboto; } .y { --face: Roboto; } .x, .y { ${read} }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } // A value set from code reads others through `var()`, with no selector. assert.deepEqual( findIndirectWeights([ scanWeights( 'libs/s8/x.component.ts', "el.style.setProperty('--face', 'var(--brand)');" ), scanWeights('libs/s8/theme.scss', `:root { --brand: ${mono}; }`), scanWeights('libs/s8/c.scss', `.x { ${read} }`), ]).map(({ value }) => value), ['700'] ); }); test('reads a compound selector with the rules it contains', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s9/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // An element of `.x:hover` (or `.x.on`, `a.x`, `.p .x:hover`) is an `.x`. for (const [source, expected] of [ [ `.x { font-family: ${mono}; } .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .x.on { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } a.x { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .p .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .x::before { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .x:hover, .y { font-weight: 700; }`, ['font-weight: 700'], ], [ `.md\\:x { font-family: ${mono}; } .md\\:x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } @media (min-width: 1px) { .x:hover { font-weight: 700; } }`, ['font-weight: 700'], ], // The rule's own family wins, a base's beats an inherited one, and a // compound that only looks alike is another element. [ `.x { font-family: ${mono}; } .x:hover { font-family: Roboto; font-weight: 700; }`, [], ], [ `.x { font-family: Roboto; } .p { font-family: ${mono}; .x:hover { font-weight: 700; } }`, [], ], [`.x { font-family: ${mono}; } .xy:hover { font-weight: 700; }`, []], [`.x { font-family: ${mono}; } .y:not(.x) { font-weight: 700; }`, []], [`.x.on { font-family: ${mono}; } .x:hover { font-weight: 700; }`, []], [`.x { font-family: ${mono}; } .x#{$s} { font-weight: 700; }`, []], // A base that inherits, or covers only some selectors, leaves the // rest to the parent. [ `.x { font-family: inherit; } .p { font-family: ${mono}; .x:hover { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { font-family: Roboto; } .p { font-family: ${mono}; .x:hover, .y { font-weight: 700; } }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads quoted family text as a name', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s9/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // In a string only a Sass `#{…}` is a variable. for (const [source, expected] of [ [ `:root { --face: Roboto; } .x { font-family: "var(--face)", ${mono}; font-weight: 700; }`, ['font-weight: 700'], ], [ `$face: Roboto; .x { font-family: "$face", ${mono}; font-weight: 700; }`, ['font-weight: 700'], ], [ `$face: Roboto; .x { font-family: "#{$face}", ${mono}; font-weight: 700; }`, [], ], [ `:root { --face: Roboto; } .x { font-family: var(--face, "a)b"), ${mono}; font-weight: 700; }`, [], ], // A quoted `)` stays in the fallback, and a quoted `var()` in a // value is no reference that could leave it invalid. [ `.x { font-family: var(--nope, "a)b", Roboto), ${mono}; font-weight: 700; }`, [], ], [ `:root { --face: "var(--x)", Roboto; } .x { font-family: var(--face, ${mono}); font-weight: 700; }`, [], ], [ `:root { --face: "a\\"var(--x)", Roboto; } .x { font-family: var(--face, ${mono}); font-weight: 700; }`, [], ], [ `.x { font-family: "a\\"var(--face)", ${mono}; font-weight: 700; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads mixin weights where they land, through nested includes', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s10/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // A mixin's weight meets the including rule's family, a later one // of its own included. [ `@mixin m { font-family: ${mono}; font-weight: 700; } .x { @include m; font-family: Roboto; }`, [], ], [ `@mixin m { font-family: Roboto; font-weight: 700; } .x { @include m; font-family: ${mono}; }`, ['font-weight: 700'], ], [ `@mixin m { font-family: ${mono}; font-weight: 700; } .x { @include m; }`, ['font-weight: 700'], ], // Not included here: it is read where it is written. [ `@mixin m { font-family: ${mono}; font-weight: 700; }`, ['font-weight: 700'], ], // A mixin included in another goes on to that one's includes. [ `@mixin inner { font-family: ${mono}; } @mixin outer { @include inner; } .x { @include outer; font-weight: 700; }`, ['font-weight: 700'], ], [ `@mixin inner { font-weight: 700; } @mixin outer { @include inner; } .x { font-family: ${mono}; @include outer; }`, ['font-weight: 700'], ], [ `@mixin a { @include b; } @mixin b { @include a; font-weight: 700; } .x { font-family: ${mono}; @include a; }`, ['font-weight: 700'], ], [ `@mixin a { @include b; } @mixin b { @include a; font-family: ${mono}; } .x { @include a; font-weight: 700; }`, ['font-weight: 700'], ], // Included in several rules: any whose family renders Mono counts. [ `@mixin h { font-weight: 700; } .a { font-family: Roboto; @include h; } .b { font-family: ${mono}; @include h; }`, ['font-weight: 700'], ], [ `:root { --f: ${mono}; } @mixin h { font-weight: 700; } .a { font-family: Roboto; @include h; } .b { font-family: var(--f); @include h; }`, ['font-weight: 700'], ], // Each include is a landing of its own: a later weight replaces it, // and an include after that replaces that weight in turn. [ `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; font-weight: 500; }`, [], ], [ `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; font-weight: 500; @include m; }`, ['font-weight: 700'], ], [ `@mixin m { font-weight: 500; } .x { font-family: ${mono}; @include m; font-weight: 700; @include m; }`, [], ], [ `@mixin m { font-weight: 700 !important; } .x { font-family: ${mono}; @include m; font-weight: 500; }`, ['font-weight: 700'], ], // Where it is replaced, its family is not the one it renders in. [ `@mixin m { font-weight: 700; } .a { font-family: ${mono}; @include m; font-weight: 500; } .b { font-family: Roboto; @include m; }`, [], ], [ `@mixin m { font-weight: 700; } .a { font-family: Roboto; @include m; font-weight: 500; } .b { font-family: ${mono}; @include m; }`, ['font-weight: 700'], ], [ `@mixin m { font: { weight: 700; } } .x { font-family: ${mono}; @include m; }`, ['weight: 700'], ], // A content block is the including rule's where the mixin places // `@content` at its top level, in order there. [ `@mixin w { font-weight: 700; @content; } .x { @include w { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `@mixin w { font-family: ${mono}; @content; } .x { @include w { font-weight: 700; } }`, ['font-weight: 700'], ], [ `@mixin w { font-weight: 700; @content; } .x { font-family: Roboto; @include w { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `@mixin w { font-weight: 700; @content; } .x { @include w { font-family: ${mono}; } font-family: Roboto; }`, [], ], [ `@mixin w { @content; font-family: Roboto; } .x { font-weight: 700; @include w { font-family: ${mono}; } }`, [], ], [ `@mixin w { @content; font-weight: 500; } .x { font-family: ${mono}; @include w { font-weight: 700; } }`, [], ], [ `@mixin w { font-weight: 500; @content; } .x { font-family: ${mono}; @include w { font-weight: 700; } }`, ['font-weight: 700'], ], [ `@mixin w { @content; } .x { font-family: ${mono}; @include w { font-weight: 700; } font-weight: 500; }`, [], ], // Sass conditions are not evaluated: any branch may run. [ `@mixin w { @if false { @content; } } .x { font-weight: 700; @include w { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `@mixin w { @if true { @content; } } .x { font-weight: 700; @include w { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `@mixin w { font-weight: 700; @content; } @mixin outer { @include w { font-family: ${mono}; } } .x { @include outer; }`, ['font-weight: 700'], ], [ `@mixin w { @content; } .a { @include w { font-family: ${mono}; } } .b { font-weight: 700; @include w { font-family: Roboto; } }`, [], ], [ `@mixin w { @content; font-weight: 500; @content; } .x { font-family: ${mono}; @include w { font-weight: 700; } }`, ['font-weight: 700'], ], [ `@mixin w { @content; } .a { font-family: ${mono}; @include w { font-weight: 700; } } .b { @include w { font-weight: 500; } }`, ['font-weight: 700'], ], // Through nested mixins, in the order Sass writes them out. [ `@mixin inner { @content; } @mixin outer { font-weight: 500; @include inner { font-weight: 700; } } .x { font-family: ${mono}; @include outer; }`, ['font-weight: 700'], ], [ `@mixin inner { @content; } @mixin outer { font-weight: 700; @include inner { font-weight: 500; } } .x { font-family: ${mono}; @include outer; }`, [], ], [ `@mixin inner { @content; font-weight: 500; } @mixin outer { @include inner { font-weight: 700; } } .x { font-family: ${mono}; @include outer; }`, [], ], [ `@mixin inner { font-weight: 500; } @mixin outer { font-weight: 700; @include inner; } .x { font-family: ${mono}; @include outer; }`, [], ], [ `@mixin a { font-weight: 700; } @mixin b { @include a; } @mixin c { @include b; font-weight: 500; } .x { font-family: ${mono}; @include c; }`, [], ], [ `@mixin inner { @content; } @mixin outer { @include inner { font-family: Roboto; } font-family: ${mono}; } .x { font-weight: 700; @include outer; }`, ['font-weight: 700'], ], [ `@mixin inner { @content; } @mixin outer { font-family: ${mono}; @include inner { font-family: Roboto; } } .x { font-weight: 700; @include outer; }`, [], ], // Placed in a nested rule, it styles another element. [ `@mixin w { font-weight: 700; .inner { @content; } } .x { @include w { font-family: ${mono}; } }`, [], ], // A placeholder styles nothing where it is written; a class still does. [ `%h { font-family: ${mono}; font-weight: 700; } .x { @extend %h; font-family: Roboto; }`, [], ], [ `.m { font-family: ${mono}; font-weight: 700; } .x { @extend .m; font-family: Roboto; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test("lands another module's mixin weights where they are included", () => { const mono = "'JetBrains Mono'"; const type = [ '@mixin heavy {', ' font-weight: 700;', '}', '@mixin light { font-weight: 400; }', '@mixin firm { font-weight: 700 !important; }', '@mixin settled { font-weight: 700; font-weight: 500; }', ].join('\n'); const report = (rules) => workspace({ 'libs/w1/_type.scss': type, 'libs/w1/c.scss': `@use 'type';\n${rules}`, }); const heavy = ['libs/w1/_type.scss:2 font-weight: 700']; for (const [rules, expected] of [ [`.x { font-family: ${mono}; @include type.heavy; }`, heavy], [`.x { font-family: Roboto; @include type.heavy; }`, []], // At the `@include`, in Sass's output order with the rule's own. [ `.x { font-family: ${mono}; @include type.heavy; font-weight: 500; }`, [], ], [ `.x { font-family: ${mono}; font-weight: 500; @include type.heavy; }`, heavy, ], [`.x { @include type.heavy; font-family: ${mono}; }`, heavy], [ `.x { font-family: ${mono}; @include type.heavy; @include type.light; }`, [], ], [ `.x { font-family: ${mono}; @include type.firm; font-weight: 500; }`, ['libs/w1/_type.scss:5 font-weight: 700'], ], // And in its order within the mixin. [`.x { font-family: ${mono}; @include type.settled; }`, []], // A nested rule inherits the family; one named through a variable // is resolved once the workspace is scanned. [`.x { font-family: ${mono}; .y { @include type.heavy; } }`, heavy], [ `:root { --face: ${mono}; } .x { font-family: var(--face); @include type.heavy; }`, heavy, ], ]) { assert.deepEqual(report(rules), expected, rules); } }); test("reports another module's mixin weight once, at its own line", () => { const mono = "'JetBrains Mono'"; const users = { 'libs/w2/a.scss': `@use 'type'; .a { font-family: ${mono}; @include type.heavy; }`, 'libs/w2/b.scss': [ "@use 'type';", `:root { --face: ${mono}; }`, '.b { font-family: var(--face); @include type.heavy; }', ].join('\n'), }; const heavy = ['libs/w2/_type.scss:1 font-weight: 700']; assert.deepEqual( workspace({ 'libs/w2/_type.scss': '@mixin heavy { font-weight: 700; }', ...users, }), heavy ); // Its own module includes it in a JetBrains Mono rule too. assert.deepEqual( workspace({ 'libs/w2/_type.scss': `@mixin heavy { font-weight: 700; } .own { font-family: ${mono}; @include heavy; }`, ...users, }), heavy ); }); test("gives a rule the family another module's mixin sets there", () => { const mono = "'JetBrains Mono'"; const report = (rules, face = `${mono}, monospace`) => workspace({ 'libs/w3/_mono.scss': `$face: ${mono}, monospace;\n@mixin face {\n font-family: ${face};\n}`, 'libs/w3/_other.scss': '@mixin face { font-family: Roboto; }', 'libs/w3/c.scss': `@use 'mono';\n@use 'other';\n${rules}`, }); const heavy = ['libs/w3/c.scss:3 font-weight: 700']; for (const [rules, expected, face] of [ [`.x { @include mono.face; font-weight: 700; }`, heavy], [ `.x { @include mono.face; font-family: Roboto; font-weight: 700; }`, [], ], [ `.x { font-family: Roboto; @include mono.face; font-weight: 700; }`, heavy, ], [`.x { @include mono.face; .y { font-weight: 700; } }`, heavy], [ `.x { @include mono.face; &:hover { font-weight: 600; } }`, ['libs/w3/c.scss:3 font-weight: 600'], ], // Named through its own module's variable, resolved there. [`.x { @include mono.face; font-weight: 700; }`, heavy, '$face'], // A namesake in another module is not the mixin included. [`.x { @include other.face; font-weight: 700; }`, []], ]) { assert.deepEqual(report(rules, face), expected, rules); } }); test('reads a mixin another module includes where it lands', () => { const mono = "'JetBrains Mono'"; const report = (rules) => workspace({ 'libs/w4/_badge.scss': `@mixin badge {\n font-family: ${mono};\n font-weight: 700;\n}`, 'libs/w4/c.scss': `@use 'badge';\n${rules}`, }); const heavy = ['libs/w4/_badge.scss:3 font-weight: 700']; assert.deepEqual( report('.x { @include badge.badge; font-family: Roboto; }'), [] ); assert.deepEqual(report('.x { @include badge.badge; }'), heavy); // Included nowhere, it is read where it is written. assert.deepEqual(report('.x { color: red; }'), heavy); }); test("resolves another module's mixin as Sass does", () => { const mono = "'JetBrains Mono'"; // A bare name a mixin of the file has is its own. assert.deepEqual( scanWeights( 'libs/w5/a.scss', '@mixin m_a { color: red; } .x { @include m-a; @include n_b; @include t.c_d; }' ).includes.map(({ callee }) => callee), [ { name: 'n-b', namespace: null }, { name: 'c-d', namespace: 't' }, ] ); const type = { 'libs/w5/_type.scss': '@mixin heavy { font-weight: 700; }' }; const heavy = ['libs/w5/_type.scss:1 font-weight: 700']; // Through a `@forward` prefix, and as a bare name `as *` or `@import` // brings in. assert.deepEqual( workspace({ ...type, 'libs/w5/_theme.scss': "@forward 'type' as type-*;", 'libs/w5/c.scss': `@use 'theme'; .x { font-family: ${mono}; @include theme.type-heavy; }`, }), heavy ); for (const load of ["@use 'type' as *;", "@import 'type';"]) { assert.deepEqual( workspace({ ...type, 'libs/w5/c.scss': `${load} .x { font-family: ${mono}; @include heavy; }`, }), heavy, load ); } // A mixin that `hide` leaves out is not the one included. assert.deepEqual( workspace({ ...type, 'libs/w5/_quiet.scss': '@mixin heavy { font-weight: 400; }', 'libs/w5/_theme.scss': "@forward 'type' hide heavy;\n@forward 'quiet';", 'libs/w5/c.scss': `@use 'theme'; .x { font-family: ${mono}; @include theme.heavy; }`, }), [] ); // Nor one declared in a rule, which is local to it. assert.deepEqual( workspace({ 'libs/w5/_type.scss': '@mixin heavy { font-weight: 400; } .p { @mixin heavy { font-weight: 700; } @include heavy; }', 'libs/w5/c.scss': `@use 'type'; .x { font-family: ${mono}; @include type.heavy; }`, }), [] ); // Two `@import`ed files that define it: Sass includes the later one, // which the scan does not order, so neither lands. assert.deepEqual( workspace({ 'libs/w5/_a.scss': '\n\n@mixin heavy { font-weight: 700; }', 'libs/w5/_b.scss': '@mixin heavy { font-weight: 400; }', 'libs/w5/c.scss': `@import 'a'; @import 'b'; .x { font-family: ${mono}; @include heavy; }`, }), [] ); }); test("follows another module's mixin through the mixins it includes", () => { const mono = "'JetBrains Mono'"; for (const [files, expected] of [ // Its family, from a mixin it includes from a third module or its // own. [ { 'libs/w6/_inner.scss': `@mixin face { font-family: ${mono}; }`, 'libs/w6/_outer.scss': "@use 'inner'; @mixin title { @include inner.face; }", 'libs/w6/c.scss': "@use 'outer'; .x { @include outer.title; font-weight: 700; }", }, ['libs/w6/c.scss:1 font-weight: 700'], ], [ { 'libs/w6/_outer.scss': `@mixin face { font-family: ${mono}; } @mixin title { @include face; }`, 'libs/w6/c.scss': "@use 'outer'; .x { @include outer.title; font-weight: 700; }", }, ['libs/w6/c.scss:1 font-weight: 700'], ], // In the order Sass writes them out, wherever each is declared. [ { 'libs/w6/_outer.scss': `@mixin title { @include face; font-family: ${mono}; } @mixin face { font-family: Roboto; }`, 'libs/w6/c.scss': "@use 'outer'; .x { @include outer.title; font-weight: 700; }", }, ['libs/w6/c.scss:1 font-weight: 700'], ], // Its weight, from a third module's mixin. [ { 'libs/w6/_inner.scss': '@mixin heavy { font-weight: 700; }', 'libs/w6/_outer.scss': "@use 'inner'; @mixin title { @include inner.heavy; }", 'libs/w6/c.scss': `@use 'outer'; .x { font-family: ${mono}; @include outer.title; }`, }, ['libs/w6/_inner.scss:1 font-weight: 700'], ], ]) { assert.deepEqual(workspace(files), expected, JSON.stringify(files)); } }); test("reads another module's mixin while its animation runs and after", () => { const mono = "'JetBrains Mono'"; const anim = { 'libs/w8/_anim.scss': [ `@keyframes face { from { font-family: ${mono}; } }`, '@keyframes swap { from { font-family: Roboto; } }', '@keyframes heavy { from { font-weight: 700; } }', '@mixin mono-hold { animation: face 1s forwards; }', '@mixin roboto-while { animation: swap 1s; }', '@mixin heavy { animation: heavy 1s; }', `@mixin mono { font-family: ${mono}; }`, ].join('\n'), 'libs/w8/_outer.scss': "@use 'anim'; @mixin title { @include anim.roboto-while; }", }; const report = (rules) => workspace({ ...anim, 'libs/w8/c.scss': `@use 'anim';\n${rules}` }); const own = ['libs/w8/c.scss:2 font-weight: 700']; for (const [rules, expected] of [ // A frame it holds sets the family over the rule's own. [ `.x { font-family: Roboto; @include anim.mono-hold; font-weight: 700; }`, own, ], // One it only runs leaves the rule its own family after, also // through a third module's mixin. [ `.x { font-family: ${mono}; @include anim.roboto-while; font-weight: 700; }`, own, ], [ `.x { font-family: Roboto; @include anim.roboto-while; font-weight: 700; }`, [], ], // Its family in a keyframe here outranks the rule's own too. [ `@keyframes k { from { @include anim.mono; } } .x { animation: k 1s forwards; font-family: Roboto; font-weight: 700; }`, own, ], [ `@use 'outer'; .x { font-family: ${mono}; @include outer.title; font-weight: 700; }`, own, ], // A keyframe's weight outranks the rule's own while it runs, a // later one too. [ `.x { font-family: ${mono}; font-weight: 500; @include anim.heavy; }`, ['libs/w8/_anim.scss:3 font-weight: 700'], ], [ `.x { font-family: ${mono}; @include anim.heavy; font-weight: 500; }`, ['libs/w8/_anim.scss:3 font-weight: 700'], ], ]) { assert.deepEqual(report(rules), expected, rules); } }); test("caps a named argument another module's mixin sets a weight from", () => { const mono = "'JetBrains Mono'"; const report = (mixin, rules) => workspace({ 'libs/w7/_type.scss': mixin, 'libs/w7/c.scss': `@use 'type';\n${rules}`, }); assert.deepEqual( report( '@mixin heavy($w: 400) { font-weight: $w; }', `.x {\n font-family: ${mono};\n @include type.heavy($w: 700);\n}` ), ['libs/w7/c.scss:4 $w: 700'] ); // Its default, where the call leaves it out. assert.deepEqual( report( '@mixin heavy($w: 700) { font-weight: $w; }', `.x { font-family: ${mono}; @include type.heavy; }` ), ['libs/w7/_type.scss:1 $w: 700'] ); }); test('runs the definition of a mixin Sass resolves at each include', () => { const mono = "'JetBrains Mono'"; const heavy = { 'libs/w9/_type.scss': '@mixin heavy { font-weight: 700; }', }; const imported = ['libs/w9/_type.scss:1 font-weight: 700']; for (const [files, expected] of [ // One declared in a rule is visible there, once declared; elsewhere // the name runs the one brought in. [ { ...heavy, 'libs/w9/c.scss': `@use 'type' as *;\n.p { @mixin heavy { font-weight: 400; } @include heavy; }\n.x { font-family: ${mono}; @include heavy; }`, }, imported, ], [ { ...heavy, 'libs/w9/c.scss': `@use 'type' as *; .p { font-family: ${mono}; @mixin heavy { font-weight: 400; } @include heavy; }`, }, [], ], [ { ...heavy, 'libs/w9/c.scss': `@use 'type' as *; .p { font-family: ${mono}; @include heavy; @mixin heavy { font-weight: 400; } }`, }, imported, ], // A rule runs the definition declared before it. [ { 'libs/w9/c.scss': `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; } @mixin m { font-weight: 400; }`, }, ['libs/w9/c.scss:1 font-weight: 700'], ], [ { 'libs/w9/c.scss': `@mixin m { font-weight: 700; } @mixin m { font-weight: 400; } .x { font-family: ${mono}; @include m; }`, }, [], ], // A mixin body runs the definition in scope where it is included. [ { 'libs/w9/c.scss': `@mixin m { font-weight: 700 !important; }\n@mixin m { font-weight: 400; }\n@mixin outer { @include m; }\n.x { font-family: ${mono}; @include outer; }`, }, [], ], [ { 'libs/w9/c.scss': `@mixin m { font-weight: 700; }\n@mixin outer { @include m; }\n.x { font-family: ${mono}; @include outer; }\n@mixin m { font-weight: 400; }\n.y { font-family: Roboto; @include outer; }`, }, ['libs/w9/c.scss:1 font-weight: 700'], ], [ { ...heavy, 'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n.x { font-family: ${mono}; @include outer; }\n@mixin heavy { font-weight: 400; }`, }, imported, ], [ { ...heavy, 'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n@mixin heavy { font-weight: 400; }\n.x { font-family: ${mono}; @include outer; }`, }, [], ], // Where it is included both before and after a local definition, // each include runs its own. [ { ...heavy, 'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n.x { font-family: ${mono}; @include outer; }\n@mixin heavy { font-weight: 400; }\n.y { font-family: Roboto; @include outer; }`, }, imported, ], [ { 'libs/w9/_firm.scss': '@mixin heavy { font-weight: 700 !important; }', 'libs/w9/c.scss': `@use 'firm' as *;\n@mixin outer { @include heavy; }\n.x { font-family: Roboto; @include outer; }\n@mixin heavy { font-weight: 400; }\n.y { font-family: ${mono}; @include outer; }`, }, [], ], [ { 'libs/w9/c.scss': `@mixin w { @content; font-weight: 400; }\n@mixin outer { @include w { font-weight: 700; } }\n.x { font-family: ${mono}; @include outer; }\n@mixin w { font-weight: 400; @content; }\n.y { font-family: Roboto; @include outer; }`, }, [], ], // A call in a definition that does not run passes nothing. [ { 'libs/w9/c.scss': `@mixin w($weight) { font-weight: $weight; }\n@mixin m { @include w($weight: 700); }\n@mixin outer { @include m; }\n.x { font-family: Roboto; @include outer; }\n@mixin m { @include w($weight: 400); }\n.y { font-family: ${mono}; @include outer; }`, }, [], ], // Included from another module, once its own module has run. [ { ...heavy, 'libs/w9/_c.scss': "@use 'type' as *;\n@mixin outer { @include heavy; }\n@mixin heavy { font-weight: 400; }", 'libs/w9/d.scss': `@use 'c'; .x { font-family: ${mono}; @include c.outer; }`, }, [], ], [ { ...heavy, 'libs/w9/_c.scss': "@use 'type' as *;\n@mixin outer { @include heavy; }", 'libs/w9/d.scss': `@use 'c'; .x { font-family: ${mono}; @include c.outer; }`, }, imported, ], // A content block goes where that definition places `@content`. [ { 'libs/w9/c.scss': `@mixin w { @content; font-weight: 500; } .x { font-family: ${mono}; @include w { font-weight: 700; } } @mixin w { font-weight: 500; @content; }`, }, [], ], // Another module sees the last definition only. [ { 'libs/w9/_t.scss': `@mixin m { font-family: ${mono}; }\n@mixin m { font-weight: 700; }`, 'libs/w9/c.scss': "@use 't'; .x { @include t.m; }", }, [], ], [ { 'libs/w9/_t.scss': '@mixin m { font-weight: 700; }\n@mixin m { font-weight: 400; }', 'libs/w9/c.scss': `@use 't'; .x { font-family: ${mono}; @include t.m; }`, }, [], ], [ { 'libs/w9/_t.scss': '@mixin m { font-weight: 400; }\n@mixin m { font-weight: 700; }', 'libs/w9/c.scss': `@use 't'; .x { font-family: ${mono}; @include t.m; }`, }, ['libs/w9/_t.scss:2 font-weight: 700'], ], [ { 'libs/w9/_p.scss': '@mixin m { font-weight: 700 !important; }\n@mixin outer { @include m; }\n.p { @include outer; }\n@mixin m { font-weight: 400; }', 'libs/w9/d.scss': `@use 'p'; .x { font-family: ${mono}; @include p.outer; }`, }, [], ], ]) { assert.deepEqual(workspace(files), expected, JSON.stringify(files)); } }); test('caps a parameter only with what calls in JetBrains Mono rules pass', () => { const mono = "'JetBrains Mono'"; const w = (fallback) => ({ 'libs/w10/_m.scss': `@mixin w($weight: ${fallback}) { font-weight: $weight; }`, }); const hops = { 'libs/w10/_p.scss': '@mixin inner($weight) { font-weight: $weight; }\n@mixin outer($w: 400) { @include inner($weight: $w); }', }; for (const [files, expected] of [ // A call in a Roboto rule passes its own weight. [ { ...w(400), 'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w($weight: 400); }\n.b { font-family: Roboto; @include m.w($weight: 700); }`, }, [], ], [ { 'libs/w10/c.scss': `@mixin w($weight: 400) { font-weight: $weight; }\n.a { font-family: ${mono}; @include w($weight: 400); }\n.b { font-family: Roboto; @include w($weight: 700); }`, }, [], ], // Its default counts where a JetBrains Mono call leaves it out. [ { ...w(700), 'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w($weight: 400); }\n.b { font-family: Roboto; @include m.w; }`, }, [], ], [ { ...w(700), 'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w; }\n.b { font-family: Roboto; @include m.w($weight: 400); }`, }, ['libs/w10/_m.scss:1 $weight: 700'], ], // Through a mixin that passes its own parameter on. [ { ...hops, 'libs/w10/c.scss': `@use 'p';\n.a { font-family: ${mono}; @include p.outer($w: 400); }\n.b { font-family: Roboto; @include p.outer($w: 700); }`, }, [], ], [ { ...hops, 'libs/w10/c.scss': `@use 'p';\n.a { font-family: ${mono}; @include p.outer($w: 700); }\n.b { font-family: Roboto; @include p.outer($w: 400); }`, }, ['libs/w10/c.scss:2 $w: 700'], ], // A call's family named through a variable, resolved. [ { ...w(400), 'libs/w10/c.scss': `@use 'm';\n:root { --f: ${mono}; }\n.a { font-family: var(--f); @include m.w($weight: 700); }`, }, ['libs/w10/c.scss:3 $weight: 700'], ], [ { ...w(400), 'libs/w10/c.scss': `@use 'm';\n:root { --f: Roboto; }\n.a { font-family: var(--f); @include m.w($weight: 700); }\n.b { font-family: ${mono}; @include m.w($weight: 400); }`, }, [], ], // Or once a keyframe that sets another family for a while ends. [ { ...w(400), 'libs/w10/c.scss': `@use 'm';\n:root { --f: ${mono}; }\n@keyframes swap { from { font-family: Roboto; } }\n.a { font-family: var(--f); animation: swap 1s; @include m.w($weight: 700); }`, }, ['libs/w10/c.scss:4 $weight: 700'], ], ]) { assert.deepEqual(workspace(files), expected, JSON.stringify(files)); } }); test("inherits the document root's family", () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)'; const report = (body) => findOffScaleWeights('libs/s10/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [ `html { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `:root { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `body { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `:host { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], // `*` sets each element itself, over what it inherits. [ `* { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `* { font-family: ${mono}; } .p { font-family: Roboto; .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `* { font-family: Roboto; } html { font-family: ${mono}; } .x { font-weight: 700; }`, [], ], [ `body { font-family: inherit; } html { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], // A nearer family wins, and `:host(.dark)` is no plain host. [ `html { font-family: ${mono}; } body { font-family: Roboto; } .x { font-weight: 700; }`, [], ], [ `html { font-family: ${mono}; } .x { font-family: Roboto; font-weight: 700; }`, [], ], [ `body { font-family: ${mono}; } .p { font-family: Roboto; .x { font-weight: 700; } }`, [], ], [`:host(.dark) { font-family: ${mono}; } .x { font-weight: 700; }`, []], // A root in the reader's `@media` applies with it; one in another // context does not always. [ `${media} { html { font-family: ${mono}; } .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `html { font-family: Roboto; } ${media} { html { font-family: ${mono}; } .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { * { font-family: ${mono}; } .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { html { font-family: ${mono}; } } .x { font-weight: 700; }`, [], ], // There, the cascade picks the root's family: a later rule, or a // `:root` over `html` on the one root element, unless `!important`. [ `:root { font-family: ${mono}; } ${media} { :root { font-family: Roboto; } .x { font-weight: 700; } }`, [], ], [ `${media} { :root { font-family: Roboto; } } :root { font-family: ${mono}; } ${media} { .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `html { font-family: ${mono}; } ${media} { :root { font-family: Roboto; } .x { font-weight: 700; } }`, [], ], [ `:root { font-family: Roboto; } html { font-family: ${mono}; } .x { font-weight: 700; }`, [], ], [ `:root { font-family: ${mono} !important; } ${media} { :root { font-family: Roboto; } .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `html { font-family: ${mono}; } ${media} { html { font-family: inherit; } .x { font-weight: 700; } }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads a static Sass interpolation in a family as Sass writes it', () => { const report = (body) => findOffScaleWeights('libs/s11/c.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [`.x { font-family: #{'JetBrains'} Mono; font-weight: 700; }`, true], [`.x { font-family: #{"JetBrains Mono"}; font-weight: 700; }`, true], [`.x { font-family: #{JetBrains} Mono; font-weight: 700; }`, true], [`.x { font-family: '#{JetBrains} Mono'; font-weight: 700; }`, true], [`.x { font-family: Jet#{'Brains'} Mono; font-weight: 700; }`, true], [ `.x { font-family: #{'Jet' + 'Brains'} Mono; font-weight: 700; }`, true, ], [ `.x { font-family: #{null}#{'JetBrains Mono'}; font-weight: 700; }`, true, ], [ `.x { font-family: #{'JetBrains'} #{'Mono'}, monospace; font-weight: 700; }`, true, ], [`.x { font: 12px #{'JetBrains'} Mono; font-weight: 700; }`, true], [ `$f: #{'JetBrains'} Mono; .x { font-family: $f; font-weight: 700; }`, true, ], // Its strings' escapes decoded as Sass unquotes them. [ `.x { font-family: #{'JetBrains\\20 Mono'}; font-weight: 700; }`, true, ], [ `.x { font-family: #{"JetBrains\\20 Mono"}; font-weight: 700; }`, true, ], [ `.x { font-family: #{'Jet' + 'Brains\\20 Mono'}; font-weight: 700; }`, true, ], [ `.x { font-family: #{"JetBrains\\" Mono"}; font-weight: 700; }`, false, ], // A quote it writes out opens a string the browser never closes. [ `.x { font-family: #{'JetBrains Mono\\', x'}; font-weight: 700; }`, false, ], // A string it writes out can hold the whole list. [ `.x { font-family: #{'JetBrains Mono, monospace'}; font-weight: 700; }`, true, ], // Spaced operands are a list: `Jet Brains` is another face. [`.x { font-family: #{'Jet' 'Brains'}; font-weight: 700; }`, false], [`.x { font-family: #{'Roboto'}; font-weight: 700; }`, false], // One that calls a function is left to its value. [ `.x { font-family: #{fn('JetBrains')} Mono; font-weight: 700; }`, false, ], ]) { assert.deepEqual( report(source), expected ? ['font-weight: 700'] : [], source ); } }); test('reads a complex rule as a base of the narrower ones it reaches', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s11/e.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // Compound by compound, last to last, each contained in the reader's. [ `.parent .x { font-family: ${mono}; } .parent .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent .x { font-family: ${mono}; } .w .parent .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent .x { font-family: ${mono}; } .parent.on .x.a { font-weight: 700; }`, ['font-weight: 700'], ], [ `.p .x { font-family: ${mono}; } .z .x:hover { font-weight: 700; }`, [], ], // A descendant across `>` or ` ` chains, `~` across `+` or `~`. [ `.a .b { font-family: ${mono}; } .a > .b.c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.p .x { font-family: ${mono}; } .p .q .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a ~ .b { font-family: ${mono}; } .a + .b.c { font-weight: 700; }`, ['font-weight: 700'], ], [`.a > .b { font-family: ${mono}; } .a .b.c { font-weight: 700; }`, []], [ `.a + .b { font-family: ${mono}; } .a ~ .b.c { font-weight: 700; }`, [], ], [ `.a > .b { font-family: ${mono}; } .a > .q > .b { font-weight: 700; }`, [], ], [ `.a + .b { font-family: ${mono}; } .a + .q + .b { font-weight: 700; }`, [], ], // Every place a span allows, not only the nearest match. [ `.a > .b .x { font-family: ${mono}; } .a > .b .b .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a + .b ~ .x { font-family: ${mono}; } .a + .b ~ .b ~ .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `.a .b .c .x { font-family: ${mono}; } .a .c .b .x { font-weight: 700; }`, [], ], // A simple selector however it is spelled. [ `.x { font-family: ${mono}; } [class~="x"] { font-weight: 700; }`, ['font-weight: 700'], ], [ `#x { font-family: ${mono}; } [id="x"] { font-weight: 700; }`, ['font-weight: 700'], ], [ `[ data-a = '1' ] { font-family: ${mono}; } [data-a="1"].on { font-weight: 700; }`, ['font-weight: 700'], ], [ `div { font-family: ${mono}; } DIV.x { font-weight: 700; }`, ['font-weight: 700'], ], // Chromium matches an SVG type in any case, too. [ `foreignobject { font-family: ${mono}; } foreignObject { font-weight: 700; }`, ['font-weight: 700'], ], [`[class="x"] { font-family: ${mono}; } .x { font-weight: 700; }`, []], // As an ancestor's rule, and ranked in the cascade. [ `.parent .x { font-family: ${mono}; } .parent .x:hover .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } .x.on .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent .x { font-family: ${mono}; } .parent .x:hover { font-family: Roboto; font-weight: 700; }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads a keyframe where a rule runs it, over its own', () => { const mono = "'JetBrains Mono'"; const run = 'animation: k 1ms steps(1) forwards;'; const report = (body) => findOffScaleWeights('libs/s11/f.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // A family or weight a keyframe sets, where a rule runs it. [ `.x { ${run} font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; ${run} } @keyframes k { to { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { animation-name: k; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `.p { ${run} } .p .x { font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], // Over the rule's own declarations, under `!important` ones. [ `.x { font-family: Roboto; ${run} font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `.x { font-family: Roboto !important; ${run} font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, [], ], [ `.x { font-family: ${mono}; font-weight: 500 !important; ${run} } @keyframes k { to { font-weight: 700; } }`, [], ], // Whatever the order or specificity of the rule's own. [ `.x { ${run} font-family: Roboto; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; ${run} font-weight: 500; } @keyframes k { to { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { ${run} } .x.y { font-family: Roboto; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `.x { ${run} } .x.y { font-family: Roboto !important; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, [], ], // One that does not hold a frame is read both while it runs and after. [ `.x { font-family: ${mono}; font-weight: 700; animation: k 1ms; } @keyframes k { to { font-family: Roboto; } }`, ['font-weight: 700'], ], [ `.x { font-family: Roboto; font-weight: 700; animation: k 1ms; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; font-weight: 500; animation: k 1ms; } @keyframes k { to { font-weight: 700; } }`, ['font-weight: 700'], ], // The keyframes it runs: the last definition, the last declaration, // a name rather than a keyword, and any for one read from a variable. [ `@keyframes k { to { font-family: ${mono}; } } @keyframes k { to { opacity: 0.5; } } .x { ${run} font-weight: 700; }`, [], ], [ `@keyframes k { to { opacity: 0.5; } } @keyframes k { to { font-family: ${mono}; } } .x { ${run} font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { ${run} animation: fade 1ms forwards; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } } @keyframes fade { to { opacity: 0.5; } }`, [], ], [ `@keyframes linear { to { font-family: ${mono}; } } .x { animation: fade 1ms linear forwards; font-weight: 700; } @keyframes fade { to { opacity: 0.5; } }`, [], ], [ `.x { --n: k; animation: var(--n) 1ms forwards; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, ['font-weight: 700'], ], [ `@keyframes linear { to { font-family: ${mono}; } } .x { animation: linear fade 1ms forwards; font-weight: 700; } @keyframes fade { to { opacity: 0.5; } }`, [], ], [ `@keyframes k { to { font-family: ${mono}; font-weight: 600; } } @keyframes k { to { opacity: 0.5; } }`, [], ], // One that holds its frame replaces the rule's own after it, too. [ `.x { font-family: ${mono}; font-weight: 700; ${run} } @keyframes k { to { font-family: Roboto; } }`, [], ], // Only the keyframes it names; a step's own family meets its weight, // wherever it is run from. [ `.x { animation: fade 1ms; font-weight: 700; } @keyframes fade { to { opacity: 0.5; } } @keyframes k { to { font-family: ${mono}; } }`, [], ], [ `@keyframes k { to { font-family: ${mono}; font-weight: 600; } }`, ['font-weight: 600'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads an `@at-root` rule where Sass writes it out', () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)'; const report = (body) => findOffScaleWeights('libs/s11/d.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // At the root, by its own selector: its source parent is no ancestor. [ `.x { font-family: ${mono}; @at-root .x { font-weight: 700; } }`, ['font-weight: 700'], ], [`.x { font-family: ${mono}; @at-root .y { font-weight: 700; } }`, []], [ `.x { font-family: ${mono}; @at-root { .y { font-weight: 700; } } }`, [], ], [ `.p { .x { font-family: ${mono}; } @at-root .x { font-weight: 700; } }`, [], ], [ `.p { font-family: ${mono}; .q { @at-root .x { font-weight: 700; } } }`, [], ], [ `.x { @at-root .x { font-family: ${mono}; } font-weight: 700; }`, ['font-weight: 700'], ], [ `.w { @at-root .p { font-family: ${mono}; } } .p .x { font-weight: 700; }`, ['font-weight: 700'], ], // A query leaves out the blocks it names, and only those. [ `.x { font-family: ${mono}; @at-root (without: media) { .c { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; ${media} { @at-root (without: media) { .c { font-weight: 700; } } } }`, ['font-weight: 700'], ], [ `${media} { .x { font-family: ${mono}; @at-root (without: media) { .c { font-weight: 700; } } } }`, [], ], [ `${media} { .x { font-family: ${mono}; } } ${media} { .p { @at-root (without: media) { .x { font-weight: 700; } } } }`, [], ], [ `.x { font-family: ${mono}; @at-root (without: rule) { .c { font-weight: 700; } } }`, [], ], [ `.x { font-family: ${mono}; @at-root (with: rule) { .c { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; ${media} { @at-root (without: all) { .c { font-weight: 700; } } } }`, [], ], [ `.x { font-family: ${mono}; @at-root (without: media) { .y & { font-weight: 700; } } } .y { font-family: Roboto; }`, ['font-weight: 700'], ], [ `.p { font-family: ${mono}; .x { @at-root (without: media) { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `${media} { .x { @at-root (without: media) { .y & { font-weight: 700; } } } } .y { font-family: ${mono}; }`, ['font-weight: 700'], ], // Its `@media` stays, and a parent it names (`&`) is still one. [ `${media} { .x { font-family: ${mono}; @at-root .x { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; @at-root &.y { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; @at-root .y { .x & { font-weight: 700; } } }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads `:is()` and `:where()` as the selectors they hold', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s11/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // The same elements written another way, either side. [ `:where(.parent .child) { font-family: ${mono}; } .parent .child { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent .child { font-family: ${mono}; } :where(.parent .child) { font-weight: 700; }`, ['font-weight: 700'], ], [ `:is(.a .b, .c) { font-family: ${mono}; } .a .b { font-weight: 700; }`, ['font-weight: 700'], ], [ `:is(.a .b, .c) { font-family: ${mono}; } .c:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `:where(.a .b) { font-family: ${mono}; } .z .b { font-weight: 700; }`, [], ], // In any compound; whole selectors first or after a descendant // combinator, where they read as elements it matches. [ `:where(.parent) .child { font-family: ${mono}; } .parent .child { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x :where(.p, .q) .c { font-family: ${mono}; } .x .q .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x :is(.a .b) { font-family: ${mono}; } .x .a .b { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x .a .b { font-family: ${mono}; } .x :is(.a .b) { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x > :is(.a .b) { font-family: ${mono}; } .x > .a .b { font-weight: 700; }`, [], ], // Beside other simples, which join its selectors' last compound. [ `:where(.parent .child).active { font-family: ${mono}; } .parent .child.active { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent .child.active { font-family: ${mono}; } :where(.parent .child).active { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x :is(.a .b).on { font-family: ${mono}; } .x .a .b.on { font-weight: 700; }`, ['font-weight: 700'], ], [ `span:where(.p .c) { font-family: ${mono}; } .p span.c { font-weight: 700; }`, ['font-weight: 700'], ], [ `*:where(.p .c) { font-family: ${mono}; } .p .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `:is(.p .c, .d).on { font-family: ${mono}; } .d.on { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x > :is(.a .b).on { font-family: ${mono}; } .x > .a .b.on { font-weight: 700; }`, [], ], // Through wrappers nested in each other, however deep. [ `:where(:is(.parent .child)) { font-family: ${mono}; } .parent .child { font-weight: 700; }`, ['font-weight: 700'], ], [ `.parent .child { font-family: ${mono}; } :where(:is(.parent .child)) { font-weight: 700; }`, ['font-weight: 700'], ], [ `:is(:where(.a .b), .c) .d { font-family: ${mono}; } .a .b .d { font-weight: 700; }`, ['font-weight: 700'], ], [ `:where(:is(:where(.p .c))).on { font-family: ${mono}; } .p .c.on { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x > :where(:is(.a .b)) { font-family: ${mono}; } .x > .a .b { font-weight: 700; }`, [], ], // An unclosed one cannot be read, but its rule's weight still is. [`.x:not(.a { font-weight: 650; }`, ['font-weight: 650']], // Specificity counts through them too: `#a` outranks the later `.x`. [ `:is(:is(:is(#a))) { font-family: ${mono}; } .x { font-family: Roboto; } #a.x { font-weight: 700; }`, ['font-weight: 700'], ], // Ranked with its own specificity: `:where()` counts nothing. [ `.parent .child { font-family: Roboto; } :where(.parent .child) { font-family: ${mono}; } .parent .child { font-weight: 700; }`, [], ], [ `.parent .child { font-family: ${mono}; } :is(.parent .child) { font-family: Roboto; } .parent .child { font-weight: 700; }`, [], ], // As an ancestor or the document root. [ `:where(.a .p) { font-family: ${mono}; } .a .p .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.p { font-family: ${mono}; } :where(.p .c) { font-weight: 700; }`, ['font-weight: 700'], ], [ `:where(html) { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads ancestors and bases as compiled, in their context', () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)'; const report = (body) => findOffScaleWeights('libs/s11/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // An ancestor in the same `@media`, or one that always applies. [ `${media} { .parent { font-family: ${mono}; } .parent .child { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.parent { font-family: ${mono}; } ${media} { .parent .child { font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { .parent { font-family: ${mono}; } } .parent .child { font-weight: 700; }`, [], ], // A family whose conditions are all the reader's applies there too, // in any order; one under a further condition does not. [ `${media} { .x { font-family: ${mono}; } @supports (display: grid) { .x { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `@supports (display: grid) { .x { font-family: ${mono}; } } ${media} { @supports (display: grid) { .x { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `${media} { html { font-family: ${mono}; } @supports (display: grid) { .x { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `@if $c { .x { font-family: ${mono}; } @if $d { .x { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `${media} { @supports (display: grid) { .x { font-family: ${mono}; } } .x { font-weight: 700; } }`, [], ], // Conditions compare however they are spaced or cased. [ `${media} { .x { font-family: ${mono}; } } @media (min-width:1px) { @supports (display: grid) { .x { font-weight: 700; } } }`, ['font-weight: 700'], ], [ `@MEDIA ( min-width : 1px ) { .x { font-family: ${mono}; } } ${media} { .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { .x { font-family: ${mono}; } } @media (min-width: 2px) { .x { font-weight: 700; } }`, [], ], // Quoted text in a condition is kept as written. [ `@container style(--t: "a, b") { .x { font-family: ${mono}; } } @container style(--t: "a,b") { .x { font-weight: 700; } }`, [], ], [ `@container style(--t: "a, b") { .x { font-family: ${mono}; } } @container style( --t : "a, b" ) { .x { font-weight: 700; } }`, ['font-weight: 700'], ], // A parent after a prefix (`.x &`) names more ancestors, read once // the parent itself (the same element) sets no family. [ `.y { .x & { font-weight: 700; } } .x { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `.y { font-family: Roboto; .x & { font-weight: 700; } } .x { font-family: ${mono}; }`, [], ], [ `.x { font-family: ${mono}; } .w .y { .x & { font-weight: 700; } }`, ['font-weight: 700'], ], // The ancestor's family is the cascade's winner there. [ `.parent { font-family: ${mono}; } ${media} { .parent { font-family: Roboto; } .parent .child { font-weight: 700; } }`, [], ], [ `${media} { .parent { font-family: Roboto; } } .parent { font-family: ${mono}; } ${media} { .parent .child { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.w .parent { font-family: Roboto; } .parent { font-family: ${mono}; } .w .parent .child { font-weight: 700; }`, [], ], // A nested selector's ancestors, as compiled, by whole selector or by // their own compound, nearest first. [ `.wrapper { .parent .child { font-weight: 700; } } .parent { font-family: ${mono}; }`, ['font-weight: 700'], ], [ `.wrapper { .parent { font-family: ${mono}; } .parent .child { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.wrapper .parent { font-family: ${mono}; } .wrapper { .parent .child { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.w { font-family: Roboto; .p .c { font-weight: 700; } } .w .p { font-family: ${mono}; }`, ['font-weight: 700'], ], // A base outside a `@media` reaches into it, not the other way. [ `.x { font-family: ${mono}; } ${media} { .x { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; } ${media} { .x:hover { font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { .x { font-family: ${mono}; } .x:hover { font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { .x { font-family: ${mono}; } } .x:hover { font-weight: 700; }`, [], ], // The parent of `&:hover` is the same element: its family beats an // ancestor's, and `*`. [ `.p { font-family: Roboto; } .p .x { font-family: ${mono}; &:hover { font-weight: 700; } }`, ['font-weight: 700'], ], [ `.x { font-family: ${mono}; &:hover { font-weight: 700; } } * { font-family: Roboto; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads an explicit `inherit` of a non-inheriting registered property', () => { const mono = "'JetBrains Mono'"; const read = 'font-family: var(--face); font-weight: 700;'; const registered = "@property --face { syntax: '*'; inherits: false; initial-value: Roboto; }"; const report = (body) => findOffScaleWeights('libs/s11/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // `inherit` takes the parent's value; `unset` gives the initial one. for (const [own, expected] of [ ['--face: inherit;', ['font-weight: 700']], ['--face: unset;', []], ['', []], ]) { assert.deepEqual( report( `${registered} body { --face: ${mono}; } .x { ${own} ${read} }` ), expected, own ); } // Another rule's `inherit` is not the reader's. assert.deepEqual( report( `${registered} body { --face: ${mono}; } .y { --face: inherit; } .x { ${read} }` ), [] ); }); test("picks the cascade's winner among the rules on an element", () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s12/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // `!important`, then specificity, then source order, over the element's // own rule, the bases it contains and `*`. for (const [source, expected] of [ [ `.x { font-family: ${mono}; } .y { font-family: Roboto; } .x.y { font-weight: 700; }`, [], ], [ `.y { font-family: Roboto; } .x { font-family: ${mono}; } .x.y { font-weight: 700; }`, ['font-weight: 700'], ], [ `div { font-family: ${mono}; } .x { font-family: Roboto; } div.x:hover { font-weight: 700; }`, [], ], [ `.x { font-family: Roboto; } div { font-family: ${mono}; } div.x:hover { font-weight: 700; }`, [], ], [ `.x { font-family: ${mono}; } .x.active { font-family: Roboto; } .x.active:hover { font-weight: 700; }`, [], ], [ `.x { font-family: ${mono} !important; } .x.active { font-family: Roboto; } .x.active:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x:hover { font-family: Roboto; font-weight: 700; } .x { font-family: ${mono}; }`, [], ], [ `.x:hover { font-weight: 700; } .x { font-family: ${mono}; } * { font-family: Roboto; }`, ['font-weight: 700'], ], [ `* { font-family: ${mono}; } .x { font-family: Roboto; } .x:hover { font-weight: 700; }`, [], ], // A rule's place is where its winning declaration is. [ `.x { font-family: ${mono}; } .y { font-family: Roboto; } .x { font-family: ${mono}; } .x.y { font-weight: 700; }`, ['font-weight: 700'], ], // An id outranks any number of classes; `:is()` takes its most // specific argument. [ `#a { font-family: Roboto; } .x { font-family: ${mono}; } #a.x { font-weight: 700; }`, [], ], [ `:is(.x, #y) { font-family: Roboto; } .x { font-family: ${mono}; } .x:hover { font-weight: 700; }`, [], ], // `:nth-child(… of S)` counts `S` too, besides itself; nested // `:is()`/`:where()` open. [ `:nth-child(1 of #i) { font-family: ${mono}; } #j { font-family: Roboto; } #j:nth-child(1 of #i) { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x:nth-child(1 of #item) { font-family: ${mono}; } .x.y { font-family: Roboto; } .x.y:nth-child(1 of #item):hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `:is(:where(.x)) { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `:is(.a, :where(.x, .y)) { font-family: ${mono}; } .y { font-weight: 700; }`, ['font-weight: 700'], ], // A reader's own `:where()`/`:is()` opens too. [ `.x { font-family: ${mono}; } :where(.x) { font-weight: 700; }`, ['font-weight: 700'], ], [ `.y { font-family: ${mono}; } :is(.x, .y) { font-weight: 700; }`, ['font-weight: 700'], ], [`.z { font-family: ${mono}; } :is(.x, .y) { font-weight: 700; }`, []], // More than 16 ways to read one is left unopened. [ `:is(.a, .b, .c, .d, .e):is(.f, .g, .h, .i) { font-family: ${mono}; } .a.f { font-weight: 700; }`, [], ], // `:where()` and `:is()` open into their selectors; `:where()` // counts nothing toward specificity. [ `:where(.x) { font-family: ${mono}; } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `:where(.x) { font-family: ${mono}; } .x { font-family: Roboto; font-weight: 700; }`, [], ], [ `.x { font-family: Roboto; } :where(.x) { font-family: ${mono}; } .x:hover { font-weight: 700; }`, [], ], [ `:is(.x, .y) { font-family: ${mono}; } .y { font-weight: 700; }`, ['font-weight: 700'], ], [`:is(.x, .y) { font-family: ${mono}; } .z { font-weight: 700; }`, []], ]) { assert.deepEqual(report(source), expected, source); } }); test('follows `@extend` through extenders and inside `@media`', () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)'; const report = (body) => findOffScaleWeights('libs/s12/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ [ `%mono { font-family: ${mono}; } %base { @extend %mono; } .x { @extend %base; font-weight: 700; }`, ['font-weight: 700'], ], // Inside a `@media`, an `@extend` reaches only the same `@media`; from // outside, it reaches into one. [ `${media} { %mono { font-family: ${mono}; } .x { @extend %mono; font-weight: 700; } }`, ['font-weight: 700'], ], [ `${media} { %mono { font-family: ${mono}; } } .x { @extend %mono; font-weight: 700; }`, ['font-weight: 700'], ], [ `%mono { font-family: ${mono}; } ${media} { .x { @extend %mono; font-weight: 700; } }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads cascade layers as always applying, ranked by layer', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s13/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // A layer is no condition. An unlayered declaration beats a layered one // whatever its specificity or place; `!important` turns that round. for (const [source, expected] of [ // `revert-layer` rolls the cascade back past its own layer. [ `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: revert-layer; font-weight: 700; } }`, ['font-weight: 700'], ], [ `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { all: revert-layer; font-weight: 700; } }`, ['font-weight: 700'], ], [ `@layer base { .x { font-family: ${mono}; } } .x { font-family: revert-layer; font-weight: 700; }`, ['font-weight: 700'], ], [ `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } @layer top { .x { font-family: revert-layer; font-weight: 700; } }`, [], ], [ `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: revert-layer; } } @layer top { .x { font-family: revert-layer; font-weight: 700; } }`, ['font-weight: 700'], ], [ `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; font-family: revert-layer; font-weight: 700; } }`, ['font-weight: 700'], ], [ `@layer base { .x { font-family: ${mono}; } } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `@layer base { .x { font-family: ${mono}; } } .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], [ `@layer base { .p { font-family: ${mono}; } } .p .c { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { font-family: Roboto; } @layer base { .x { font-family: ${mono}; } } .x:hover { font-weight: 700; }`, [], ], [ `@layer base { .d.e { font-family: ${mono}; } } .d { font-family: Roboto; } .d.e:hover { font-weight: 700; }`, [], ], [ `@layer base { .x { font-family: ${mono} !important; } } .x { font-family: Roboto !important; } .x:hover { font-weight: 700; }`, ['font-weight: 700'], ], // Named layers rank in declared order (`@layer a, b;`, else as they // first appear), later winning; `!important` reverses it. [ `@layer theme, base; @layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `@layer base, theme; @layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } .x { font-weight: 700; }`, [], ], [ `@layer theme, base; @layer base { .x { font-family: ${mono} !important; } } @layer theme { .x { font-family: Roboto !important; } } .x { font-weight: 700; }`, [], ], [ `@layer one { .x { font-family: Roboto; } } @layer two { .x { font-family: ${mono}; } } .x { font-weight: 700; }`, ['font-weight: 700'], ], [ `@layer { .x { font-family: Roboto; } } @layer two { .x { font-family: ${mono}; } } .x { font-weight: 700; }`, ['font-weight: 700'], ], // A nested layer ranks with its top-level layer. [ `@layer a, c; @layer a { @layer b { .x { font-family: ${mono}; } } } @layer c { .x { font-family: Roboto; } } .x { font-weight: 700; }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('ranks nested and unnamed cascade layers by their full place', () => { const mono = "'JetBrains Mono'"; const weight = '.x { font-weight: 700; }'; const report = (body) => findOffScaleWeights('libs/s14/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // Sublayers rank in their declared order within their layer. [ `@layer base { @layer theme, fonts; @layer fonts { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } } ${weight}`, ['font-weight: 700'], ], [ `@layer base { @layer fonts, theme; @layer fonts { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } } ${weight}`, [], ], // Each unnamed layer is its own, the later winning. [ `@layer named { .x { font-family: ${mono}; } } @layer { .x { font-family: Roboto; } } ${weight}`, [], ], [ `@layer { .x { font-family: Roboto; } } @layer { .x { font-family: ${mono}; } } ${weight}`, ['font-weight: 700'], ], [ `@layer { .x { font-family: ${mono}; } } @layer { .x { font-family: Roboto; } } ${weight}`, [], ], // A layer's own declarations beat its sublayers', `!important` the // other way; `x.y` is a sublayer of `x`. [ `@layer p { .x { font-family: ${mono}; } @layer q { .x { font-family: Roboto; } } } ${weight}`, ['font-weight: 700'], ], [ `@layer p { .x { font-family: ${mono} !important; } @layer q { .x { font-family: Roboto !important; } } } ${weight}`, [], ], [ `@layer x.y { .x { font-family: Roboto; } } @layer x { .x { font-family: ${mono}; } } ${weight}`, ['font-weight: 700'], ], // A layer is placed where it first appears, a block with no family // or a dotted name (`x.y` places `x`) included. [ `@layer a { .y { color: red; } } @layer b { .x { font-family: Roboto; } } @layer a { .x { font-family: ${mono}; } } ${weight}`, [], ], [ `@layer x.y, z; @layer z { .x { font-family: Roboto; } } @layer x { .x { font-family: ${mono}; } } ${weight}`, [], ], // However deep: five levels compare as fully as one. [ `@layer a.b.c.d.theme, a.b.c.d.fonts; @layer a.b.c.d.fonts { .x { font-family: ${mono}; } } @layer a.b.c.d.theme { .x { font-family: Roboto; } } ${weight}`, ['font-weight: 700'], ], [ `@layer a.b.c.d.fonts, a.b.c.d.theme; @layer a.b.c.d.fonts { .x { font-family: ${mono}; } } @layer a.b.c.d.theme { .x { font-family: Roboto; } } ${weight}`, [], ], [ `@layer a.b.c.d { .x { font-family: ${mono}; } } @layer a.b.c.d.e { .x { font-family: Roboto; } } ${weight}`, ['font-weight: 700'], ], [ `@layer a.b.c.d { .x { font-family: ${mono} !important; } } @layer a.b.c.d.e { .x { font-family: Roboto !important; } } ${weight}`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads an `all` reset as resetting the family and weight', () => { const mono = "'JetBrains Mono'"; const parent = `.parent { font-family: ${mono}; }`; const report = (body) => findOffScaleWeights('libs/s15/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); for (const [source, expected] of [ // `unset` and `revert` inherit the family, `initial` takes the // browser's; a later or `!important` family still wins. [ `${parent} .parent .a { font-family: Roboto; all: unset; font-weight: 700; }`, ['font-weight: 700'], ], [ `${parent} .parent .a { font-family: Roboto; all: revert; font-weight: 700; }`, ['font-weight: 700'], ], [ `${parent} .parent .a { font-family: Roboto; all: initial; font-weight: 700; }`, [], ], [ `${parent} .parent .a { all: unset; font-family: Roboto; font-weight: 700; }`, [], ], [ `${parent} .parent .a { font-family: Roboto !important; all: unset; font-weight: 700; }`, [], ], // A weight before it is reset too, unless `!important`; and it resets // the family either way. [`.a { font-family: ${mono}; font-weight: 700; all: unset; }`, []], [ `.a { font-family: ${mono}; font-weight: 700 !important; all: unset; }`, [], ], [ `.a { all: unset; font-family: ${mono}; font-weight: 700; }`, ['font-weight: 700'], ], // An `!important` reset beats a later family, and a later weight // unless that is `!important` too. [ `${parent} .parent .a { all: unset !important; font-family: Roboto; font-weight: 700 !important; }`, ['font-weight: 700'], ], [ `${parent} .parent .a { all: unset !important; font-family: Roboto; font-weight: 700; }`, [], ], // A weight before it is gone even where the family is set again // after it, and an earlier `!important` weight outranks a later one. [`.a { font-weight: 700; all: unset; font-family: ${mono}; }`, []], [ `.a { font-family: ${mono}; font-weight: 500 !important; font-weight: 700; }`, [], ], // Through a mixin, at its `@include`. [ `@mixin reset { all: unset; } ${parent} .parent .a { font-family: Roboto; font-weight: 700; @include reset; }`, [], ], [ `@mixin reset { all: unset; } ${parent} .parent .a { font-family: Roboto; @include reset; font-weight: 700; }`, ['font-weight: 700'], ], // Only as a declaration: not in another value or a Sass map. [ `${parent} .parent .a { font-family: Roboto; --token: all: unset; font-weight: 700; }`, [], ], [ `${parent} .parent .a { font-family: Roboto; $m: (all: unset); font-weight: 700; }`, [], ], [`.a { --face: font-family: ${mono}; font-weight: 700; }`, []], [ `.a { font-family: ${mono}; font-weight: 700; --x: font-weight: 400; }`, ['font-weight: 700'], ], [ `.a { font-family: ${mono}; font-weight: 700; --t: all: unset; }`, ['font-weight: 700'], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('compiles a nested `&` after its parent', () => { const mono = "'JetBrains Mono'"; const report = (body) => findOffScaleWeights('libs/s13/b.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); // `.x { & .child {} }` is `.x .child`, not `.child .x`. for (const [source, expected] of [ [ `.x { & .child { font-family: ${mono}; } } .x .child .item { font-weight: 700; }`, ['font-weight: 700'], ], [ `.x { & .child { font-family: ${mono}; } } .child .x .item { font-weight: 700; }`, [], ], ]) { assert.deepEqual(report(source), expected, source); } }); test('reads template literals set from code', () => { const component = [ "renderer.setStyle(el, 'font-weight', `65${0}`);", 'el.style.fontWeight = `${wide ? 750 : 600}`;', "el.style.fontWeight = `${'6'}${'50'}`;", // An unknown value alone is that value; text around it computes one. 'el.style.fontWeight = `${weight}`;', 'el.style.fontWeight = `6${w}`;', // A shorthand's other tokens still read as CSS. 'el.style.font = `${650} 12px Roboto`;', 'el.style.font = `${w} 12px Roboto`;', // So does CSS text in a template. 'el.style.cssText = `font-weight: 65${0}`;', 'el.style.fontWeight = `600`;', // A number as JavaScript writes it into a string. 'el.style.fontWeight = `${0x28a}`;', // A nested template, or more than 16 texts, is not expanded. 'el.style.fontWeight = `${`6${5}`}0`;', 'el.style.fontWeight = `${a ? 6 : 6}${b ? 5 : 5}${c ? 0 : 0}${d ? 0 : 0}${e ? 0 : 0}`;', ].join('\n'); assert.deepEqual( findOffScaleWeights('apps/web/src/a.component.ts', component) .findings.map(({ line, value, computed }) => computed ? `${line} ${value} (computed)` : `${line} ${value}` ) .sort(), [ '1 650', '10 650', '11 `${`6${5}`}0` (computed)', '12 `${a ? 6 : 6}${b ? 5 : 5}${c ? 0 : 0}${d ? 0 : 0}${e ? 0 : 0}` (computed)', '2 750', '3 650', '5 `6${w}` (computed)', '6 650', '8 650', ] ); }); test('reads custom properties as the cascade applies them', () => { const read = '.x { font-weight: var(--w); }'; // A later declaration in the same rule replaces an earlier one. assert.deepEqual( offScale('libs/m5/a.scss', `:root { --w: 650; --w: 600; } ${read}`), [] ); for (const rule of [ ':root { --w: 650 !important; --w: 600; }', ':root { --w: 650; @if $a { --w: 600; } }', ]) { assert.deepEqual(offScale('libs/m5/a.scss', `${rule} ${read}`), [ '1 --w: 650', ]); } // A fallback stays live unless a rule that reaches the reading rule // sets the property: `:root`, the rule itself, an enclosing one, or the // component's `:host`. const mono = "font-family: var(--face, 'JetBrains Mono'); font-weight: 700;"; const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const rule = (body) => scanWeights('libs/m5/rule.scss', body); assert.deepEqual(report([rule(`.a { --face: Roboto; } .b { ${mono} }`)]), [ 'libs/m5/rule.scss:1 700', ]); for (const body of [ `.b { --face: Roboto; ${mono} }`, `.a { --face: Roboto; .b { ${mono} } }`, `:host { --face: Roboto; } .b { ${mono} }`, `html, .theme { --face: Roboto; } .b { ${mono} }`, `:where(:root) { --face: Roboto; } .b { ${mono} }`, `:is(html, .theme) { --face: Roboto; } .b { ${mono} }`, `:host, :host(.x) { --face: Roboto; } .b { ${mono} }`, `@media (min-width: 1px) { .b { --face: Roboto; ${mono} } }`, ]) { assert.deepEqual(report([rule(body)]), []); } // `body` sits below `html`, so it cannot set a property `html` reads. assert.deepEqual( report([rule(`body { --face: Roboto; } html { ${mono} }`)]), ['libs/m5/rule.scss:1 700'] ); assert.deepEqual( report([rule(`body { --face: Roboto; } .b { ${mono} }`)]), [] ); // A definition under a condition, or a host state, may not apply; one // the reading declaration shares does. assert.deepEqual( report([rule(`@if $on { .b { --face: Roboto; ${mono} } }`)]), [] ); for (const body of [ `.b { @if $on { --face: Roboto; } ${mono} }`, `@media (min-width: 600px) { :root { --face: Roboto; } } .b { ${mono} }`, `@if $dark { :root { --face: Roboto; } } .b { ${mono} }`, `:host(.light) { --face: Roboto; } .b { ${mono} }`, `@media (min-width: #{$bp}) { :root { --face: Roboto; } } .b { ${mono} }`, ]) { assert.deepEqual(report([rule(body)]), ['libs/m5/rule.scss:1 700']); } assert.deepEqual( report([ scanWeights('libs/m5/other.scss', ':host { --face: Roboto; }'), rule(`.b { ${mono} }`), ]), ['libs/m5/rule.scss:1 700'] ); // The same condition in another file is another rule's condition. const media = '@media (min-width: 1px) {'; assert.deepEqual( report([ scanWeights( 'libs/m5/other.scss', `${media} :root { --face: Roboto; } }` ), rule(`${media} .b { ${mono} } }`), ]), ['libs/m5/rule.scss:1 700'] ); }); test('reads a family Sass assembles from variables', () => { const report = (body) => findIndirectWeights([scanWeights('libs/m5/s.scss', body)]).map( ({ line, value }) => `${line} ${value}` ); const weight = 'font-weight: 700;'; assert.deepEqual( report( `$prefix: JetBrains; .x { font-family: '#{$prefix} Mono'; ${weight} }` ), ['1 700'] ); assert.deepEqual( report( `$a: 'JetBrains'; $b: Mono; .x { font-family: $a $b, monospace; ${weight} }` ), ['1 700'] ); assert.deepEqual( report( `$base: JetBrains; $prefix: $base; .x { font-family: '#{$prefix} Mono'; ${weight} }` ), ['1 700'] ); assert.deepEqual( report( `$prefix: Roboto; .x { font-family: '#{$prefix} Mono'; ${weight} }` ), [] ); // Chains of any length resolve, and a cycle ends. assert.deepEqual( report( `$a: JetBrains; $b: $a; $c: $b; $d: $c; $prefix: $d; .x { font-family: '#{$prefix} Mono'; ${weight} }` ), ['1 700'] ); assert.deepEqual( report(`$a: $b; $b: $a; .x { font-family: '#{$a} Mono'; ${weight} }`), [] ); // A package module's variable cannot be resolved; the name written // beside it still counts. assert.deepEqual( report( `@use 'pkg:lib' as lib; $mono: 'JetBrains Mono', lib.$rest; .x { font-family: $mono; ${weight} }` ), ['1 700'] ); }); test('resolves family variables in their place in the list', () => { const report = (body) => findOffScaleWeights('libs/m8/a.scss', body).findings.map( ({ name, value }) => `${name}: ${value}` ); const shorthand = "font: 700 var(--size) var(--face), 'JetBrains Mono';"; // `var(--size)` is the size; `var(--face)` comes first in the list. assert.deepEqual( report(`:root { --face: Roboto; --size: 12px; } .x { ${shorthand} }`), [] ); assert.deepEqual( report( `:root { --face: 'SF Mono'; --size: 12px; } .x { ${shorthand} }` ), ['font: 700'] ); assert.deepEqual( report( ":root { --face: Roboto; } .y { font-family: var(--face), 'JetBrains Mono'; font-weight: 700; }" ), [] ); // A comma set apart still ends the entry before it. assert.deepEqual( report( ":root { --face: Roboto; --size: 12px; } .x { font: 700 var(--size) var(--face) , 'JetBrains Mono'; }" ), [] ); // Weight and size from variables: the family follows the size. assert.deepEqual( report( ":root { --weight: 700; --size: 12px; } .x { font: var(--weight) var(--size) 'JetBrains Mono'; }" ), ['--weight: 700'] ); // A whole shorthand from variables, its family a variable too. assert.deepEqual( report( ":root { --size: 16px; --face: 'JetBrains Mono'; --body: 700 var(--size) var(--face); } .x { font: var(--body); }" ), ['--body: 700'] ); // Any branch that can render Mono counts. assert.deepEqual( report( ":root { --face: Roboto; } .z { --face: 'SF Mono'; } .y { font-family: var(--face), 'JetBrains Mono'; font-weight: 700; }" ), ['font-weight: 700'] ); }); test('caps a var() branch only where its own family can render Mono', () => { const report = (body) => findIndirectWeights([scanWeights('libs/m7/a.scss', body)]).map( ({ line, value }) => `${line} ${value}` ); const fallback = "font: var(--f, 500 16px 'JetBrains Mono');"; assert.deepEqual( report(`.a { --f: 700 16px Roboto; } .x { ${fallback} }`), [] ); assert.deepEqual( report(`.a { --f: 700 16px 'JetBrains Mono'; } .x { ${fallback} }`), ['1 700'] ); assert.deepEqual( report(".a { --w: 700; } .x { font: var(--w) 16px 'JetBrains Mono'; }"), ['1 700'] ); // A whole shorthand from a variable is read as a shorthand. assert.deepEqual( report(".a { --f: 700 16px 'JetBrains Mono'; } .x { font: var(--f); }"), ['1 700'] ); // A family the shorthand reads through a variable may be Mono. assert.deepEqual( report( `.a { --face: 'JetBrains Mono'; --f: 700 16px var(--face); } .x { ${fallback} }` ), ['1 700'] ); }); test('caps the weights a JetBrains Mono rule reads through variables', () => { const mono = "font-family: 'JetBrains Mono'"; assert.deepEqual( offScale( 'libs/m3/b.scss', `$mono-w: 600; .a { ${mono}; font-weight: $mono-w; } .b { font-weight: $mono-w; }` ), ['1 $mono-w: 600'] ); // A `…weight` name is checked where it is declared; the cap adds one // finding, and an off-scale value is not reported twice. assert.deepEqual( offScale( 'libs/m3/c.scss', `$title-weight: 600; .a { ${mono}; font-weight: $title-weight; }` ), ['1 $title-weight: 600'] ); assert.deepEqual( offScale( 'libs/m3/d.scss', `$title-weight: 650; .a { ${mono}; font-weight: $title-weight; }` ), ['1 $title-weight: 650'] ); assert.deepEqual( offScale( 'libs/m3/e.scss', ".x { --w: 700; } .a { font: var(--w) 12px 'JetBrains Mono'; }" ), ['1 --w: 700'] ); // A `…weight` keyword is not reported again. assert.deepEqual( offScale( 'libs/m3/f.scss', `$title-weight: bolder; .a { ${mono}; font-weight: $title-weight; }` ), ['1 $title-weight: bolder'] ); // A chain read from both kinds of rule keeps the cap, in either order. for (const rules of [ `.n { font-weight: $a; } .m { ${mono}; font-weight: $a; }`, `.m { ${mono}; font-weight: $a; } .n { font-weight: $a; }`, ]) { assert.deepEqual( offScale('libs/m3/g.scss', `$b: 600; $a: $b; ${rules}`), ['1 $b: 600'] ); } // Custom properties set from code, as CSS text or as an expression. const host = scanWeights( 'libs/m3/x.component.ts', [ "host: { '[style.--mono-w]': \"'700'\",", " '[style.--mono-v]': 'wide ? 700 : 500' }", ].join('\n') ); const rule = scanWeights( 'libs/m3/x.component.scss', `.a { ${mono}; font-weight: var(--mono-w); } .b { ${mono}; font-weight: var(--mono-v); }` ); assert.deepEqual( findIndirectWeights([host, rule]) .map(({ line, value }) => `${line} ${value}`) .sort(), ['1 700', '2 700'] ); }); test('lets every configured load replace a partial default', () => { const report = (scans) => findIndirectWeights(scans).map( ({ file, line, value }) => `${file}:${line} ${value}` ); const rule = '$w: 650 !default; .t { font-weight: $w; }'; const tokens = scanWeights('libs/c3/_tokens.scss', rule); const load = (file, body) => scanWeights(`libs/c3/${file}`, body); const configured = load('a.scss', "@use 'tokens' with ($w: 600);"); const flagged = ['libs/c3/_tokens.scss:1 650']; assert.deepEqual(report([tokens, configured]), []); // A quoted `;` or `//` in the configuration is a value. assert.deepEqual( report([ tokens, load( 'a.scss', "@use 'tokens' with ($asset: 'data:image/svg+xml;utf8,x', $w: 600);" ), ]), [] ); assert.deepEqual( report([ tokens, load('a.scss', "@use 'tokens' with ($asset: '//cdn/x', $w: 600);"), ]), [] ); assert.deepEqual( report([ tokens, load('a.scss', "@use 'tokens' with ($asset: '//cdn/x', $w: 750);"), ]), ['libs/c3/a.scss:1 750'] ); assert.deepEqual( report([tokens, configured, load('b.scss', "@use 'tokens';")]), flagged ); assert.deepEqual(report([tokens]), flagged); // Only a `with (…)` setting that name counts. assert.deepEqual( report([tokens, load('a.scss', "@use 'tokens' with ($other: 600);")]), flagged ); assert.deepEqual( report([ tokens, configured, load('b.scss', "@use 'tokens'; .x { @include m($w: 600); }"), ]), flagged ); // A block's own `!default` is not a module setting. assert.deepEqual( report([ scanWeights( 'libs/c3/_local.scss', '.t { $w: 650 !default; font-weight: $w; }' ), load('a.scss', "@use 'local' with ($w: 600);"), ]), ['libs/c3/_local.scss:1 650'] ); // Forwarding cycles end. assert.deepEqual( report([ scanWeights('libs/c3/_x.scss', `@forward 'y'; ${rule}`), load('_y.scss', "@forward 'x';"), ]), ['libs/c3/_x.scss:1 650'] ); assert.deepEqual( report([tokens, load('a.scss', "@use 'tokens' with ($w: null);")]), flagged ); // A file that is not a partial may also be compiled on its own. assert.deepEqual( report([ scanWeights('libs/c3/entry.scss', rule), load('a.scss', "@use 'entry' with ($w: 600);"), ]), ['libs/c3/entry.scss:1 650'] ); // Through a `@forward`, its own `with (…)` or its loads, under the prefix. assert.deepEqual( report([ tokens, load('_mod.scss', "@forward 'tokens' with ($w: 600);"), load('u.scss', "@use 'mod';"), ]), [] ); const mod = load('_mod.scss', "@forward 'tokens' as p-*;"); assert.deepEqual( report([tokens, mod, load('u.scss', "@use 'mod' with ($p-w: 600);")]), [] ); assert.deepEqual( report([ tokens, mod, load('u.scss', "@use 'mod' with ($p-w: 600);"), load('v.scss', "@use 'mod';"), ]), flagged ); }); test('blanks every `//` comment in TypeScript', () => { const component = [ 'call(// font-weight: 650 was removed', ' value);', 'const a = { b: // font-weight: 750', ' 1 };', ].join('\n'); assert.deepEqual(offScale('apps/web/src/a.component.ts', component), []); }); test('flags relative keywords, which can land off the scale', () => { const findings = findOffScaleWeights( 'libs/a.scss', 'strong { font-weight: bolder; }' ).findings; assert.deepEqual( findings.map(({ value }) => value), ['bolder'] ); assert.match(describeFinding(findings[0]), /relative to the parent/); }); test('ignores weights in comments and keeps later line numbers exact', () => { const source = [ '/* The heading used to be', ' font-weight: 650; */', '// font-weight: 750 looked too heavy', ".a { src: url('https://example.test/f.woff2'); }", '.b { font-weight: 450; }', ].join('\n'); assert.deepEqual(offScale('libs/a.scss', source), ['5 font-weight: 450']); assert.equal(blankComments(source).split('\n').length, 5); assert.match(blankComments(source), /https:\/\/example\.test/); }); test('treats a selector that merely contains "weight" as a selector', () => { const source = '.title-weight:nth-child(2) { font-weight: 600; }'; assert.deepEqual(offScale('libs/a.scss', source), []); }); test('checks CSS text in TypeScript and HTML, not variables named …Weight', () => { const component = [ 'const yearWeight = 100;', 'const ranking = { popularityWeight: 0.3, titleWeight: 250 };', 'styles: [', " '.chip { font-size: 0.72rem; font-weight: 650; }',", '],', 'const style = { fontWeight: 750 };', ].join('\n'); const template = '

Hi

'; assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ '4 font-weight: 650', '6 fontWeight: 750', ]); assert.deepEqual(offScale('apps/web/src/index.html', template), [ '1 font-weight: 300', ]); }); test('suggests the scale step the audit mapping uses', () => { const mapping = Object.fromEntries( [300, 450, 520, 530, 610, 650, 680, 750, 760, 800].map((weight) => [ weight, nearestScaleWeight(weight), ]) ); assert.deepEqual(mapping, { 300: 400, 450: 500, 520: 500, 530: 500, 610: 600, 650: 600, 680: 600, 750: 700, 760: 700, 800: 700, }); assert.match( describeFinding({ file: 'libs/a.scss', line: 3, name: 'font-weight', value: 650, }), /^libs\/a\.scss:3 font-weight: 650 is off the 400\/500\/600\/700 scale.*Use 600\.$/ ); }); test('scans app and library styles and sources, not the landing site', () => { assert.equal(isScannedFile('libs/ui/styles/_detail-view.scss'), true); assert.equal(isScannedFile('apps/web/src/styles.scss'), true); assert.equal(isScannedFile('apps/web/src/app/app.component.ts'), true); assert.equal(isScannedFile('apps/web/src/index.html'), true); assert.equal(isScannedFile('apps/web/src/assets/images/logo.svg'), true); assert.equal(isScannedFile('apps/website/src/styles/global.css'), false); assert.equal(isScannedFile('tools/nx/check-font-weights.mjs'), false); assert.equal(isScannedFile('libs/ui/styles/README.md'), false); }); test('fails instead of passing when the scan finds no stylesheets', () => { assert.match(validateScanCoverage([])[0], /No stylesheets were scanned/); assert.match( validateScanCoverage(['apps/web/src/app/app.component.ts'])[0], /No stylesheets were scanned/ ); assert.deepEqual(validateScanCoverage(['apps/web/src/styles.scss']), []); });