mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render real semibold and bold faces instead of a synthetic bold, keep <html lang> in step with the UI language, and move every font weight onto the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard LIVE badge now uses the interface font at 700). Add the `styles:font-weights:validate` ratchet guard and its CI step. It reads stylesheets much as Sass and the browser do (cascade, layers, mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`, keyframes) and lists what it deliberately does not trace in its header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5751 lines
212 KiB
JavaScript
5751 lines
212 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
|
|
import {
|
|
MONO_WEIGHT_CAP,
|
|
blankComments,
|
|
describeFinding,
|
|
findIndirectWeights,
|
|
findOffScaleWeights,
|
|
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}`
|
|
);
|
|
|
|
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 = '<p style="font-weight: 600">Top 100 channels</p>';
|
|
|
|
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 = [
|
|
'<p [style.font-weight]="active() ? 650 : 400">a</p>',
|
|
'<p [style.fontWeight]="items.length > 3 ? 700 : 500">b</p>',
|
|
].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 `<style>` block's
|
|
// shorthand keeps its `/line-height`.
|
|
assert.deepEqual(
|
|
offScale(
|
|
'apps/web/src/a.component.html',
|
|
'<style>.x { font: italic 12px/200 Roboto; }</style>'
|
|
),
|
|
[]
|
|
);
|
|
});
|
|
|
|
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',
|
|
`<div style="font: 700 16px Roboto, 'JetBrains Mono'"></div>`
|
|
),
|
|
[]
|
|
);
|
|
});
|
|
|
|
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',
|
|
'<p style="font-w\\65 ight: 650">x</p>'
|
|
),
|
|
['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 = [
|
|
'<p style="font-weight: 650"></p>',
|
|
'<p style="font-weight: 650"></p>',
|
|
'<p style="font-weight: 750; font-weight: 400"></p>',
|
|
'<p style="font-weight: 650"></p>',
|
|
'<p style="font-family: "JetBrains Mono"; font-weight: 700"></p>',
|
|
"<p style='font-family: "JetBrains Mono"; font-weight: 700'></p>",
|
|
// A decoded line break keeps later lines in place.
|
|
'<p style=" font-weight: 650"></p>',
|
|
// Not decoded: `<style>` text, a reference to `&`, and comments.
|
|
'<style>.a { font-weight: 650; }</style>',
|
|
'<p style="font-weight: &#54;50"></p>',
|
|
'<!-- <p style="font-weight: 650"></p> -->',
|
|
'<!-- --> <p style="font-weight: 650"> -->',
|
|
// `<` and `>` stay text: no tag opens or closes.
|
|
'<p><i style="font-weight: 650"></p>',
|
|
'<p title=a>b style="font-weight: 750"></p>',
|
|
// Past the last code point is U+FFFD.
|
|
'<p title="�" style="font-weight: 650"></p>',
|
|
].join('\n');
|
|
const svg = [
|
|
'<svg><text font-weight="650">x</text>',
|
|
'<style>.a { font-weight: 750; }</style>',
|
|
'<style><![CDATA[.b { font-weight: 650; }]]></style></svg>',
|
|
].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',
|
|
`<p title="${emoji}"></p>\n<i style="font-weight: 650"></i>\n<b></b>`
|
|
),
|
|
['2 font-weight: 650']
|
|
);
|
|
assert.deepEqual(
|
|
offScale(
|
|
'apps/web/src/a.component.html',
|
|
'<p title=" "></p>\n<i style="font-w\\65 ight: 650"></i>'
|
|
),
|
|
['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 = [
|
|
`<div [style]="'font-weight:' + 650"></div>`,
|
|
`<div [attr.style]="'font-weight: ' + (wide ? 750 : 600)"></div>`,
|
|
`<div [style]='"font: " + 650 + " 12px Roboto"'></div>`,
|
|
`<div [style]="'font-family: JetBrains Mono; font-weight:' + 600"></div>`,
|
|
// An escaped quote stays in the string.
|
|
`<div [style]="'a: it\\'s; font-weight:' + 650"></div>`,
|
|
// The rest of the expression is not part of the value.
|
|
`<div [style]="'font-weight:' + 600 + ';'"></div>`,
|
|
`<div [style]="'font: ' + 14 + 'px Roboto'"></div>`,
|
|
`<div [style]="'color: red; font-weight:' + w + ';'"></div>`,
|
|
// Only a `+` concatenates.
|
|
`<div [style]="'font-weight:' || 650"></div>`,
|
|
// A blank string adds nothing.
|
|
`<div [style]="'font-weight:' + ' ' + 650"></div>`,
|
|
`<div [style]="'font-weight:' + '' + 750 + ';'"></div>`,
|
|
].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 = [
|
|
'<code>font-weight: 650</code>',
|
|
'<p title="font-weight: 650">x</p>',
|
|
'<p style="font-weight: 650">x</p>',
|
|
'<style>.a { font-weight: 750; }</style>',
|
|
`<div [ngStyle]="{ 'font-weight': 650 }"></div>`,
|
|
'<style>/* font-weight: 650; */ .x { font-weight: 600; content: "/* kept"; }</style>',
|
|
// A CSS string holds no comment; after `</style>` it is markup again.
|
|
'<style>.y { content: "/* x"; font-weight: 750; }</style>',
|
|
'<p>/* text</p>',
|
|
'<p style="font-weight: 650">y</p>',
|
|
'<code>font-weight: 650</code>',
|
|
// An unquoted `style` value runs to a space or `>`.
|
|
'<div style=font-weight:650>x</div>',
|
|
'<div title=font-weight:650>y</div>',
|
|
'<div style=font-weight:600 class=x>z</div>',
|
|
// Text that reads like an attribute, and a non-style binding.
|
|
'<p>Set style=font-weight:650 here</p>',
|
|
`<div [title]="'font-weight: 650'"></div>`,
|
|
].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: '<number>'; inherits: false; initial-value: 650; } .x { font-weight: var(--title-weight); }"
|
|
),
|
|
['--title-weight: 650']
|
|
);
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<number>'; initial-value: 750; inherits: false; } .y { font-weight: var(--w); }"
|
|
),
|
|
['--w: 750']
|
|
);
|
|
// A `<number>` takes an exponent; an `<integer>` takes neither an
|
|
// exponent nor a fraction.
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<number>'; inherits: false; initial-value: 6.5e2; } .y { font-weight: var(--w); }"
|
|
),
|
|
['--w: 6.5e2']
|
|
);
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<integer>'; inherits: false; initial-value: 650; } .y { font-weight: var(--w); }"
|
|
),
|
|
['--w: 650']
|
|
);
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<integer>'; 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: '<number>'; inherits: false; initial-value: calc(600 + 50); } .y { font-weight: var(--w); }"
|
|
),
|
|
['--w: calc(600 + 50)']
|
|
);
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<number>'; inherits: false; initial-value: calc(1px + 2px); } .y { font-weight: var(--w, 600); }"
|
|
),
|
|
[]
|
|
);
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<number>'; 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: '<number>'; 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: '<color>'; inherits: false; initial-value: 650; } .x { font-weight: var(--title-weight, 600); }"
|
|
),
|
|
[]
|
|
);
|
|
assert.deepEqual(
|
|
report(
|
|
"@property --w { syntax: '<number>'; 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: '<length>'; 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 = [
|
|
'<svg><text font-weight="650">x</text>',
|
|
'<style>.a { font-weight: 750; }</style>',
|
|
'<!-- font-weight="900" --><text font-weight="700">y</text></svg>',
|
|
].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 = [
|
|
`<div style="font: 700 16px 'JetBrains Mono'"></div>`,
|
|
`<div style="font-family: 'JetBrains Mono'; font-weight: 700"></div>`,
|
|
'<div style="font-weight: 700"></div>',
|
|
`<div style="font-family: 'JetBrains Mono'; font-family: sans-serif; font-weight: 700"></div>`,
|
|
`<div style="font-family: 'JetBrains Mono' !important; font-family: sans-serif; font-weight: 700"></div>`,
|
|
`<div style="font-family: 'JetBrains Mono'; font: 12px; font-weight: 700"></div>`,
|
|
].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 = [
|
|
'<div [ngStyle]="{ fontWeight: viewportWidth >= 768 ? 700 : 600 }"></div>',
|
|
`<div [ngStyle]="{ 'font-weight': wide ? 650 : 400 }"></div>`,
|
|
`<div [style]="'font-weight: 750'"></div>`,
|
|
'<p style="font-weight: 650">text</p>',
|
|
`<div [ngStyle]='{ "font-weight": w >= 768 ? 700 : 600 }'></div>`,
|
|
// A string in a binding is CSS, so `/200` is a line-height.
|
|
'<div [style]="`font: italic 12px/200 Roboto`"></div>',
|
|
`<div [style]='"font: italic 12px/200 Roboto"'></div>`,
|
|
].join('\n');
|
|
const component =
|
|
'template: `<div [ngStyle]="{ fontWeight: w >= 768 ? 700 : 600 }"></div>`,';
|
|
|
|
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 = [
|
|
"<p>Don't wrap</p>",
|
|
'<p style="font: 750 12px \'DM Sans\'">x</p>',
|
|
].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 = [
|
|
'<p [style.font-weight]="600 + 50">a</p>',
|
|
'<p [style.font-weight]="active() ? 700 : 400">b</p>',
|
|
].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 = [
|
|
'<svg><text font-weight="650">a</text></svg>',
|
|
"<svg><text font-weight='600'>b</text></svg>",
|
|
'<svg><text [attr.font-weight]="600 + 50">c</text></svg>',
|
|
'<p [style.font-weight]="650">d</p>',
|
|
'<svg><text font-weight=750>e</text></svg>',
|
|
'<svg><text font-weight=600>f</text></svg>',
|
|
'<p>font-weight=750 and font-weight="650" as text</p>',
|
|
'<svg><text aria-label="1 > 0" font-weight="520">g</text></svg>',
|
|
'<p title="font-weight=650">a < b</p><p>a < b font-weight=650</p>',
|
|
].join('\n');
|
|
const component = [
|
|
`template: '<svg><text font-weight="750">x</text></svg>',`,
|
|
'template: "<svg><text aria-label=\\"x > 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');",
|
|
'<p [style.--title]="active() ? 450 : 400"></p>',
|
|
"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',
|
|
`<div style="font-family: ${mono} ! important; font-family: sans-serif; font-weight: 700"></div>`
|
|
),
|
|
['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("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 = '<p style="font-weight: 300">Hi</p>';
|
|
|
|
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']), []);
|
|
});
|