Files
iptvnator/tools/nx/check-font-weights.mjs
T
4grayandClaude Opus 5.5 e8b181fcea fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780)
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>
2026-10-04 08:55:23 +02:00

2214 lines
95 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractStylesheetLoads } from './check-stylesheet-inputs.mjs';
import {
blocksOf,
calleeOf,
closingBrace,
codeExpression,
codeStringAt,
concatenatedAfter,
decodeSource,
inBinding,
inConditionPrelude,
inMarkupCss,
inUnquotedStyle,
insideTag,
invocationsOf,
lex,
lineIndex,
namesCallable,
placeOf,
resultsOf,
templateParts,
tokensOf,
valueAfter,
} from './font-weight-lexer.mjs';
import {
ALL_RESET,
IMPORTANT,
startsDeclaration,
MONO_WEIGHT_CAP,
canonicalSelector,
declarationText,
atRootExcludes,
blockKind,
familiesOf,
entryVerdict,
familyEntries,
familyParts,
fontNamespaceRule,
hasLiteralSize,
parsesAsFont,
keyOrder,
levelOf,
plainHost,
reachDepth,
selectorList,
reachesEverything,
rendersMono,
shorthandFamilies,
} from './font-weight-family.mjs';
import {
effectiveDeclarations,
exposedName,
sassScopes,
} from './font-weight-scope.mjs';
/**
* The weights `apps/web/src/styles.scss` bundles for DM Sans and its Roboto
* fallback (JetBrains Mono stops at 500). Any other value snaps to a
* neighbouring face (650 renders as 700), and a 600 or 700 that finds
* nothing heavier than 500 gets Chromium's synthetic bold. The workspace is
* at zero exceptions; this check keeps it there.
*
* Sass is not compiled, so a weight must be written, not computed: arithmetic
* and functions other than `var()` are findings in themselves. Every weight
* written in a declaration counts, `var()` fallbacks included: a fallback
* renders as soon as its property is unset anywhere. Definitions reached
* through variables count only where they can render. Variables and
* the callables their named arguments go to are followed by name (`-` and `_`
* alike), through `@forward … as prefix-*` and its `show`/`hide` lists too.
* A parameter default counts where a call leaves it out, or when no call is
* in sight. A weight set from code is read per value it can take, so a
* condition's numbers are not weights, a template literal as each text its
* literal `${…}` parts produce (`` `65${0}` `` is 650; a weight it builds
* around another value is computed), and CSS text that a string leaves to
* code (`'font-weight:' + w`) is read from the operand after the `+`. A
* partial's `!default` gives way where every load of it configures the
* name. A file is read as the browser reads it: CSS escapes in names
* decoded as Sass decodes them (`font-w\65 ight` is `font-weight`),
* markup's character references too (`&#54;50` is 650), and a family's
* static interpolation as Sass writes it out (`#{'Jet' + 'Brains'}` is
* `JetBrains`). Not traced:
* JavaScript escapes in TypeScript (`fontW\u0065ight`), positional mixin
* or function arguments, calls through `meta.apply`, `meta.load-css` and
* `@function` return values, so pass weights as named `$…weight` arguments;
* nor values TypeScript stores and binds later (a component field, signal or
* input read by `[style.fontWeight]="weight"`), so bind a literal or keep
* the field's literal on the scale.
*
* A stylesheet rule set in JetBrains Mono (its own `font-family` or `font`,
* written out or through variables, or one a nested rule inherits; see
* `familiesOf`) is capped at `MONO_WEIGHT_CAP`. A mixin's top-level
* declarations land where this file includes it, in the order Sass writes
* them out, a content block's too where the mixin places `@content` at its
* top level, a rule this file `@extend`s whole applies to its extenders, and
* a keyframe's declarations (of its last definition) apply where a rule's
* last `animation` runs it, over the rule's own and under `!important` ones,
* while it runs and, unless it holds a frame (`forwards`, `both`,
* `infinite`), the rule's own after. An `:is()` or `:where()` reads as the
* selectors it holds (`:where(.p) .c` is `.p .c`). A rule also sets the
* family of the narrower selectors it reaches (`.x` for `.x:hover`, `.p .x`
* for `.w .p .x:hover`), compound by compound across what its combinators
* allow; of those, the element's own rule and `*`, the cascade winner counts
* (`!important`, layer, specificity, source order; a `@layer` always
* applies, and `revert-layer` falls back past its own). Without a family of
* its own, a rule takes one from an ancestor its compiled selector names (an
* `@at-root` rule's as Sass writes it out), else from the document root
* (`:host`, `body`, `html`, `:root`) in its file; a family applies under
* conditions (`@media`, `@supports`, `@if`) that the reader shares, or
* always. Sass conditions are not evaluated: each `@if`/`@else` branch
* counts as one that may run, in a rule, a mixin or a content block alike.
*
* Not traced: global styles in another file, a weight inherited from another
* rule, a mixin from another module, a mixin's nested rules and at-rules
* (and a `@content` placed in one), a family set on an element from code,
* and one that reaches only some of a rule's elements (a more specific
* `.x.active`, or `@extend .m` into `.m.active`). Animations are read as a
* whole: a keyframe's steps cascade as one rule rather than as states in
* turn, a rule's last `animation` runs whatever an earlier `!important` one
* sets, one layer holding a frame holds all of them, and a quoted name is
* read word by word.
*/
export const WEIGHT_SCALE = Object.freeze([400, 500, 600, 700]);
export { MONO_WEIGHT_CAP };
const BOLD = /\bbold\b/gi;
/**
* Not app UI: the landing site ships a variable font, so any weight is a real
* one there.
*/
const EXCLUDED_PREFIXES = [
'apps/website/',
// Server-side SVG artwork in system Arial, served as images.
'apps/xtream-mock-server/',
'libs/shared/marketing-fixtures/',
];
const STYLESHEET = /\.(s?css)$/;
const SOURCE = /\.(ts|html|svg)$/;
/**
* Stylesheets feed weights through `font-weight`, custom properties, Sass
* variables and Material token maps, so any name ending in `weight` counts.
* In TypeScript, HTML and SVG only CSS text is in scope, never a variable that
* happens to be called `…Weight`. CSS names, keywords and functions are
* case-insensitive, so every pattern below that matches CSS text is too.
*/
const STYLESHEET_WEIGHT = /(?<![\w$-])((?:\$|--)?[\w-]*weight)\s*:/gi;
const SOURCE_WEIGHT =
/(?<![\w$-])(font-weight|fontWeight|--[\w-]*weight)['"`]?\]?\s*:/gi;
const FONT_SHORTHAND = /(?<![\w$-])(font)\s*:/gi;
/**
* A static HTML or SVG presentation attribute, quoted (`font-weight="650"`,
* also with escaped quotes inside a TypeScript string) or unquoted
* (`font-weight=650`).
*/
const ATTRIBUTE_WEIGHT =
/(?<![\w$-])(font-weight)\s*=\s*(?:\\?(['"])(.*?)\\?\2|([^\s>'"=<`\\]+))/gi;
/** A custom property or Sass variable that a weight value may refer to. */
const DEFINITION = /(?<![\w$-])((?:\$|--)[\w-]+)\s*:/g;
/**
* Angular bindings (or `host`) that set a weight or a custom property:
* `[style.font-weight]`, `[attr.font-weight]`, `[style.--title]`.
*/
const CODE_BINDING =
/(\[(?:style|attr)\.(font-weight|fontWeight|font|--[\w-]+)\])['"]?\s*[:=]\s*(['"])([\s\S]*?)\3/gi;
/**
* Angular `@HostBinding('style.fontWeight')` (or `style.font-weight`,
* `style.--x`, `attr.font-weight`) on a field (`= value`) or a getter
* (`return value`).
*/
const HOST_BINDING =
/@HostBinding\(\s*(['"`])(style|attr)\.(font-weight|fontWeight|font|--[\w-]+)\1\s*\)/g;
const HOST_GETTER =
/^\s*(?:(?:public|private|protected|override|static)\s+)*get\s+[\w$]+\s*\(\s*\)\s*(?::[^{]+)?\{/;
const HOST_FIELD =
/^\s*(?:(?:public|private|protected|readonly|override|static)\s+)*[\w$]+\s*[!?]?\s*(?::[^=;]+)?=(?!=)/;
/**
* A Sass property name built by interpolation (`font-#{weight}`,
* `#{'font-weight'}`, `#{$prop}`).
*/
const INTERPOLATED_NAME = /(?<![\w$#{-])((?:[\w-]*#\{[^{}]*\})+[\w-]*)\s*:/g;
/**
* DOM writes of the weight or the `font` shorthand, dotted or indexed
* (`style['font-weight']`). `===` compares, so only a lone `=` (or `+=` and
* kin, or a logical `||=`) assigns.
*/
const CODE_ASSIGNMENT =
/(\.style(?:\.(font(?:Weight)?)|\[\s*(['"`])(font(?:Weight|-weight)?)\3\s*\]))\s*(\*\*|[-+*/%]|\|\||&&|\?\?)?=(?!=)/g;
/** A logical assignment stores its right-hand side as it is. */
const LOGICAL_ASSIGNMENT = /^(?:\|\||&&|\?\?)$/;
/**
* Runtime setters, by the arguments before the property name and the names
* that set a weight or a custom property: `style.setProperty(name, value)`,
* Angular's `Renderer2.setStyle(element, 'fontWeight', value, flags?)` and
* SVG presentation attributes, `setAttribute(NS)`. Each argument is read as
* a whole expression, so `setStyle(wrap(getEl()), …)` counts too.
*/
const CODE_SETTER =
/\b(setProperty|setStyle|setAttribute|setAttributeNS)\s*\(/g;
const SETTERS = {
setProperty: { skip: 0, property: /^(?:font-weight|font|--[\w-]+)$/i },
setStyle: { skip: 1, property: /^(?:font-?weight|font|--[\w-]+)$/i },
setAttribute: { skip: 0, property: /^font-weight$/i },
setAttributeNS: { skip: 1, property: /^font-weight$/i },
};
const QUOTED_NAME = /^(\s*(['"`])([^'"`]*)\2)\s*,/;
/**
* CSS-wide keywords on a custom property: `initial` leaves it unset;
* `inherit` and `unset` take the parent's value, whatever another rule
* sets, and so do `revert` and `revert-layer` (the browser has no value of
* its own for it, and a lower layer's is another rule).
*/
const RESETTING = /^initial\b/i;
/** At-rules whose body applies only under a condition. */
const CONDITIONAL_RULE = /^@(?:media|supports|container|document)\b/i;
const INHERITING = /^(?:inherit|unset|revert|revert-layer)\b/i;
const WEIGHT_SETTER = /(?<![\w$-])(font-weight|font|weight)\s*:/gi;
/** A property name that sets or holds a weight. */
const WEIGHT_NAME = /^(?:font|font-weight|(?:\$|--)?[\w-]*weight)$/i;
/** A family declaration in CSS text: `font-family`, or the `font` shorthand. */
const FAMILY_DECLARATION = /(?<![\w-])(font-family|font)\s*:\s*([^;]*)/gi;
/** A CSS number token, whole: `650`, `.65e3`, `6.5E2` (not `650.`). */
const NUMBER_TOKEN = /^[+-]?(?:\d*\.\d+|\d+)(?:e[+-]?\d+)?$/i;
/** An integer token: no fraction and no exponent (`6e2` is a number). */
const INTEGER_TOKEN = /^[+-]?\d+$/;
/** The math functions of plain numbers, with how many arguments each takes. */
const MATH_ARITY = Object.freeze({
...{ calc: [1, 1], min: [1, Infinity], max: [1, Infinity], clamp: [3, 3] },
...{ round: [1, 2], mod: [2, 2], rem: [2, 2], abs: [1, 1], sign: [1, 1] },
...{ pow: [2, 2], sqrt: [1, 1], hypot: [1, Infinity], log: [1, 2] },
exp: [1, 1],
});
const MATH_CONSTANT = /^(?:e|pi|-?infinity|nan)$/i;
const ROUNDING = /^(?:nearest|up|down|to-zero)$/i;
/** One token of a math value: whitespace, a number, a name or function, a sign. */
const MATH_TOKEN =
/\s+|[+-]?(?:\d*\.\d+|\d+)(?:e[+-]?\d+)?(?![\w%.])|-?[a-z][\w-]*\(?|[()+\-*/,]/iy;
/** A math value's tokens, each with whether whitespace precedes it. */
function mathTokens(value) {
const tokens = [];
let space = false;
MATH_TOKEN.lastIndex = 0;
while (MATH_TOKEN.lastIndex < value.length) {
const token = MATH_TOKEN.exec(value)?.[0];
if (token === undefined) return null;
if (/^\s/.test(token)) space = true;
else tokens.push({ text: token, space });
if (!/^\s/.test(token)) space = false;
}
return tokens;
}
/**
* Whether `value` is a math function of plain numbers (`calc(600 + 50)`,
* `max(600, 650)`): computationally independent, so a valid `<number>` or
* `<integer>` initial value. It parses as CSS does: each function takes its
* number of arguments, `+` and `-` need whitespace on both sides, and a unit,
* `%` or `var()` makes it invalid there.
*/
function numericMath(value) {
const tokens = mathTokens(value.trim());
if (!tokens || !/\($/.test(tokens[0]?.text ?? '')) return false;
let i = 0;
const next = () => tokens[i]?.text;
const sum = () => {
if (!product()) return false;
while (next() === '+' || next() === '-') {
if (!tokens[i].space || !tokens[i + 1]?.space) return false;
i += 1;
if (!product()) return false;
}
return true;
};
const product = () => {
if (!operand()) return false;
while (next() === '*' || next() === '/') {
i += 1;
if (!operand()) return false;
}
return true;
};
const operand = () => {
const token = next() ?? '';
i += 1;
if (/^[+-]?[\d.]/.test(token) || MATH_CONSTANT.test(token)) return true;
if (token === '(') return sum() && tokens[i++]?.text === ')';
const name = /^([a-z][\w-]*)\($/i.exec(token)?.[1].toLowerCase();
if (!name || !Object.hasOwn(MATH_ARITY, name)) return false;
if (name === 'round' && ROUNDING.test(next() ?? '')) {
if (tokens[i + 1]?.text !== ',') return false;
i += 2;
}
let count = 0;
do {
if (!sum()) return false;
count += 1;
} while (next() === ',' && ++i);
const [fewest, most] = MATH_ARITY[name];
return tokens[i++]?.text === ')' && count >= fewest && count <= most;
};
return operand() && i === tokens.length;
}
/**
* Whether a `@property` body is a valid registration of `value`: it has a
* `syntax` and `inherits`, and the syntax accepts the value (`*` anything,
* `<number>` a number, `<integer>` an integer, either a math function of
* numbers, `<custom-ident>`/`<string>` a name).
*/
function registrationAccepts(body, value) {
const syntax = /(?<![\w-])syntax\s*:\s*(['"])(.*?)\1/i.exec(body)?.[2];
const inherits = /(?<![\w-])inherits\s*:\s*(?:true|false)\b/i.test(body);
if (syntax === undefined || !inherits) return false;
if (syntax.trim() === '*') return true;
if (numericMath(value)) return /<(?:number|integer)>/i.test(syntax);
if (!NUMBER_TOKEN.test(value)) {
return /<(?:custom-ident|string)>/i.test(syntax);
}
return (
/<number>/i.test(syntax) ||
(INTEGER_TOKEN.test(value) && /<integer>/i.test(syntax))
);
}
/** A run of whitespace, or a quoted string (its spaces are its own). */
const SPACING = /(['"])(?:\\[\s\S]|(?!\1)[^\\])*\1|\s+/g;
/**
* Whether rules with the selector chains `a` style every element that ones
* with `b` do (see `selectorsOf` in `scanWeights`).
*/
function covers(a, b) {
return Boolean(a && b?.length) && b.every((chain) => a.includes(chain));
}
/** A registered custom property: `@property --x { … }`. */
const PROPERTY_RULE = /@property\s+(--[\w-]+)\s*\{/gi;
/** Sass loops that bind variables: `@each $a, $b in …`, `@for $i from …`. */
const EACH_LOOP = /@each\s+((?:\$[\w-]+\s*,\s*)*\$[\w-]+)\s+in\s+([^{]+)\{/gi;
const FOR_LOOP =
/@for\s+(\$[\w-]+)\s+from\s+([\s\S]+?)\s+(through|to)\s+([\s\S]+?)\s*\{/gi;
/**
* Computed code: arithmetic next to a number (`600 + 50` is 650, also with a
* signed operand as in `600 - -50`), or a minus (or a `+` before a bracket)
* in front of a number: `-(-650)`.
*/
const CODE_ARITHMETIC =
/\d\s*(?:\*\*|[-+*/%])\s*[-+]*\s*[\w$(.'"`]|[\w$).'"`]\s*(?:\*\*|[-+*/%])\s*[-+]*\s*\.?\d|(?:^|[^\w$).'"`\]\s])\s*(?:-\s*[\d.(]|\+\s*\()/;
/** A CSS <number>: decimals, an exponent and a `+` sign are all valid. */
const NUMBER_TEXT = String.raw`\+?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?`;
const NUMBER = new RegExp(
String.raw`(?<![\w.#$-])(${NUMBER_TEXT})(?![\w.%])`,
'gi'
);
const RELATIVE_KEYWORD = /\b(bolder|lighter)\b/gi;
/** `var(--x)`, `$x` or a module member `ns.$x`. */
const REFERENCE = /var\(\s*(--[\w-]+)|(?:([\w-]+)\.)?(\$[\w-]+)/gi;
/**
* One token of a weight expression: a number (with any unit), an identifier
* (opening a call when `(` follows), an operator or a parenthesis.
*/
const EXPRESSION_TOKEN =
/\s*(?:((?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?[a-z%]*)|([$-]*[a-z_][\w-]*(?:\.[$\w-]+)*)(\()?|([-+*/%])|([()]))/giy;
/**
* A shorthand token that is one variable: `var(--x)` (with an optional
* fallback), `$x` or an interpolated `#{$x}`.
*/
const VARIABLE_TOKEN =
/^(?:var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)|(?:([\w-]+)\.)?(\$[\w-]+)|#\{\s*(?:([\w-]+)\.)?(\$[\w-]+)\s*\})$/i;
export function isScannedFile(file) {
const normalized = file.split(path.sep).join('/');
if (!/^(apps|libs)\//.test(normalized)) return false;
if (EXCLUDED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return false;
}
return STYLESHEET.test(normalized) || SOURCE.test(normalized);
}
/** Comments blanked, newlines kept, so reported line numbers match the file. */
export function blankComments(source, file = 'source.scss') {
return lex(file, source).text;
}
/** Scale step to use instead: rounds down between 600 and 700 (620–680). */
export function nearestScaleWeight(weight) {
if (weight < 450) return 400;
if (weight < 550) return 500;
if (weight < 700) return 600;
return 700;
}
/** Sass treats `-` and `_` in a name alike; custom properties are exact. */
function identity(name) {
return name.startsWith('$') ? name.replace(/_/g, '-') : name;
}
/**
* Sass arithmetic and functions compile to a value the source does not show
* (`400 + 500` is 900, `-(-650)` is 650), so a CSS weight that uses them is
* reported whole. The one sign that is part of a number is a `+` written
* directly before it (`+700`). TypeScript `${…}` holds code, whose numbers
* are checked as terms instead.
*/
function computedIn(value) {
const css = value.replace(/\$\{[^}]*\}/g, ' 0 ');
let previous = '';
EXPRESSION_TOKEN.lastIndex = 0;
while (EXPRESSION_TOKEN.lastIndex < css.length) {
const start = EXPRESSION_TOKEN.lastIndex;
const match = EXPRESSION_TOKEN.exec(css);
if (!match) {
EXPRESSION_TOKEN.lastIndex = start + 1;
previous = '';
continue;
}
const [, number, identifier, call, operator, paren] = match;
if (call && identifier.toLowerCase() !== 'var') return true;
// `+700` is a number; any other sign or operator computes a value.
const signed =
operator === '+' &&
previous !== 'operand' &&
/^[\d.]/.test(css.slice(EXPRESSION_TOKEN.lastIndex));
if (operator && !signed) return true;
previous =
!call && (number || identifier || paren === ')') ? 'operand' : '';
}
return false;
}
/**
* What a value contributes to a weight: off-scale terms as written, and the
* variables to follow. `font` reads the value as the shorthand, whose weight
* comes before the size and the family: a token counts only with two more
* after it (so a TypeScript `font: 12` property is not a weight), and never
* the size with its `/line-height` or what follows a `/`. A variable token
* may stand for any stretch of the shorthand (`font: italic $body`), so it is
* followed as shorthand, carrying how many tokens come after it (`after`).
* A weight is 1 to 1000, so `0`, the one unitless font size, is never one.
*/
function analyse(mode, value, options = {}) {
const { minimum = 1, code = false, after = 0, cap = null } = options;
if (mode === 'font') {
const tokens = tokensOf(value);
const weight = [];
const terms = [];
const references = [];
tokens.forEach((token, index) => {
// Only "two or more after" matters; capping keeps cycles finite.
const tail = Math.min(tokens.length - 1 - index + after, 2);
const variable = VARIABLE_TOKEN.exec(token);
if (variable) {
const [, custom, fallback, ns, sass, innerNs, interpolated] =
variable;
const name = identity(custom ?? sass ?? interpolated);
const namespace = ns ?? innerNs ?? null;
references.push({
...{ name, namespace, mode: 'font', after: tail },
cap,
});
const inner = analyse('font', fallback ?? '', {
after: tail,
cap,
});
terms.push(...inner.terms);
references.push(...inner.references);
return;
}
const previous = tokens[index - 1] ?? '';
if (tail >= 2 && !token.includes('/') && !previous.endsWith('/')) {
weight.push(token);
}
});
const own = analyse('weight', weight.join(' '), { cap });
return {
terms: [...terms, ...own.terms],
references: [...references, ...own.references],
};
}
// Above `cap`, a scale weight (or `bold`, 700) gets a synthetic bold.
const heavy = (term) =>
cap !== null && Number(/^bold$/i.test(term) ? 700 : term) > cap;
const numbers = [...value.matchAll(NUMBER)]
.map((match) => match[1])
.filter((term) => Number(term) >= minimum)
.filter((term) => !WEIGHT_SCALE.includes(Number(term)) || heavy(term));
const keywords = [
...(value.match(RELATIVE_KEYWORD) ?? []),
...(value.match(BOLD) ?? []).filter(heavy),
];
const computed = code ? CODE_ARITHMETIC.test(value) : computedIn(value);
return {
terms: computed
? [{ value: value.trim(), computed: true }]
: [...numbers, ...keywords].map((term) =>
heavy(term) ? { value: term, cap } : { value: term }
),
references: [...value.matchAll(REFERENCE)].map((match) => ({
name: identity(match[1] ?? match[3]),
namespace: match[2] ?? null,
mode: 'weight',
cap,
})),
};
}
/** A Sass variable in a family, interpolated (`#{$x}`) or not (`ns.$x`). */
const SASS_VALUE =
/#\{\s*((?:[\w-]+\.)?\$[\w-]+)\s*\}|((?:[\w-]+\.)?\$[\w-]+)/g;
/** A Sass variable's value as it is substituted: no flags, no outer quotes. */
function sassValue(value) {
return value
.replace(/!(?:default|global)\b/gi, '')
.trim()
.replace(/^(['"])(.*)\1$/, '$2');
}
/**
* The variables a family reads (see `familyParts`), as references resolved
* from `at` (a file, position and scope): each custom property with its
* `var()` fallback, then any Sass variable named outright.
*/
function familyRefs({ outside, vars }, at) {
const place = {
...{ file: at.file, index: at.index, scopes: at.scopes },
...{ inCallable: at.inCallable, callable: at.callable },
...{ guards: at.guards ?? [], rule: at.rule ?? null },
selectors: at.selectors ?? null,
// Read inside a `font` shorthand: values are shorthands too.
shorthand: at.shorthand ?? false,
};
return [
...vars.map(({ name, fallback }) => ({
...{ name, namespace: null, mode: 'family', fallback },
...place,
})),
// A string is a name, unless Sass interpolates in it (`"#{$x}"`).
...[
...outside
.replace(/(['"])(?:\\[\s\S]|(?!\1)[^\\])*\1/g, (m) =>
m.includes('#{') ? m : ' '.repeat(m.length)
)
.matchAll(REFERENCE),
].map((match) => ({
name: identity(match[1] ?? match[3]),
namespace: match[2] ?? null,
...{ mode: 'family', fallback: null },
...place,
})),
];
}
/**
* The names a Sass property name can compose to: a quoted or bare literal
* interpolation stands for itself and a variable for each value
* `valuesOf` gives it (none when it cannot be resolved), up to 16.
*/
function composedNames(name, valuesOf) {
let names = [''];
let last = 0;
for (const match of name.matchAll(/#\{\s*([^{}]*?)\s*\}/g)) {
const inner = match[1];
const literal = /^(['"])(.*)\1$/.exec(inner);
let values = [];
if (literal) values = [literal[2]];
else if (/^[\w-]+$/.test(inner)) values = [inner];
else if (/^\$[\w-]+$/.test(inner)) values = valuesOf(inner);
const between = name.slice(last, match.index);
names = names
.flatMap((prefix) => values.map((v) => prefix + between + v))
.slice(0, 16);
last = match.index + match[0].length;
}
return names.map((prefix) => prefix + name.slice(last));
}
/**
* Where the parameters end before a TypeScript return type that `before`
* ends with (`): { weight: number }`, `): Promise<{ a: 1 }>`), or -1: the
* return type is read back as one balanced expression to its `:`.
*/
function returnTypeStart(before) {
let depth = 0;
for (let k = before.length - 1; k >= 0; k -= 1) {
const char = before[k];
if (char === '>' && before[k - 1] === '=') {
k -= 1;
} else if ('})]>'.includes(char)) {
depth += 1;
} else if ('{([<'.includes(char)) {
if (depth === 0) return -1;
depth -= 1;
} else if (depth === 0 && char === ':') {
return before.slice(0, k).trimEnd().length;
} else if (depth === 0 && /[;=]/.test(char)) {
return -1;
}
}
return -1;
}
/**
* Whether the `{` at `brace` opens a function body: after `=>`, or after a
* parameter list that no `if`/`for`/`while`/`switch`/`catch` owns.
*/
function opensFunction(text, brace) {
let before = text.slice(0, brace).trimEnd();
if (before.endsWith('=>')) return true;
// A TypeScript return type sits between the parameters and the body.
const typed = returnTypeStart(before);
if (typed !== -1) before = before.slice(0, typed);
if (!before.endsWith(')')) return false;
let depth = 0;
let k = before.length - 1;
for (; k >= 0; k -= 1) {
if (before[k] === ')') depth += 1;
else if (before[k] === '(' && (depth -= 1) === 0) break;
}
const word = /([\w$]+)\s*$/.exec(before.slice(0, k))?.[1] ?? '';
return !/^(?:if|for|while|switch|catch|with)$/.test(word);
}
/** Whether `index` sits where a definition exists: a loop variable's body. */
function inLoopOf(definition, index) {
const { loop } = definition;
return !loop || (loop.start < index && index < loop.end);
}
/**
* Whether the family in effect in a block of CSS text is JetBrains Mono: the
* last `font-family` or parsing `font` declaration, unless an earlier one is
* `!important`.
*/
function familyInCss(css) {
let effective = null;
for (const [, property, value] of css.matchAll(FAMILY_DECLARATION)) {
if (property.toLowerCase() === 'font' && !parsesAsFont(value)) continue;
const important = IMPORTANT.test(value);
if (effective?.important && !important) continue;
const shorthand = property.toLowerCase() === 'font';
effective = { important, mono: rendersMono(value, { shorthand }) };
}
return effective?.mono ?? false;
}
/**
* A `@for` variable read as a weight: a range of literal bounds is one
* weight only when both are the same scale weight; any other range (or a
* bound Sass computes) yields values the source does not show.
*/
function rangeAnalysis({ range, value }) {
const [from, to] = [Number(range.from), Number(range.to)];
const literal = Number.isFinite(from) && Number.isFinite(to);
// `through` includes the end, `to` stops before it (`from 6 to 6` runs
// no times).
const count = Math.abs(to - from) + (range.through ? 1 : 0);
const silent =
literal &&
(count === 0 || (count === 1 && WEIGHT_SCALE.includes(from)));
return {
terms: silent ? [] : [{ value, computed: true }],
references: [],
};
}
/**
* The positions in a Sass list where `char` sits outside parentheses and
* strings (`("a, b" 650, c)` has one top-level comma).
*/
function topLevel(text, char) {
const at = [];
let depth = 0;
let quote = '';
for (let i = 0; i < text.length; i += 1) {
if (quote) {
if (text[i] === '\\') i += 1;
else if (text[i] === quote) quote = '';
} else if (text[i] === '"' || text[i] === "'") quote = text[i];
else if (text[i] === '(') depth += 1;
else if (text[i] === ')') depth -= 1;
else if (text[i] === char && depth === 0) at.push(i);
}
return at;
}
/** Where a list item's `:` sits outside parentheses and strings, or -1. */
function topLevelColon(item) {
return topLevel(item, ':')[0] ?? -1;
}
/** A Sass list split at its top-level commas, outer parentheses dropped. */
function listItems(list) {
const inner = /^\((.*)\)$/s.exec(list.trim())?.[1] ?? list;
const items = [];
let from = 0;
for (const comma of topLevel(inner, ',')) {
items.push(inner.slice(from, comma).trim());
from = comma + 1;
}
return [...items, inner.slice(from).trim()].filter(Boolean);
}
/**
* What each variable of `@each $a, $b in …` takes, as a list: for a map,
* the first the keys and the second the values; for a list of lists, each
* its position in every item (a quoted `"wide label"` is one element). One
* variable takes the whole list.
*/
function loopColumns(list, count) {
if (count === 1) return [list];
const items = listItems(list);
// A map entry has its `:` outside parentheses (`(a b): 650` too).
const colons = items.map(topLevelColon);
const map = colons.every((colon) => colon !== -1);
const rows = items.map((item, i) =>
map
? [item.slice(0, colons[i]), item.slice(colons[i] + 1)]
: tokensOf(item.replace(/^\((.*)\)$/s, '$1'))
);
return Array.from({ length: count }, (_, i) =>
rows
.map((row) => (row[i] ?? '').trim())
.filter(Boolean)
.join(', ')
);
}
/**
* A term that only the JetBrains Mono cap makes a finding: a scale weight
* (or `bold`) above it. An off-scale one is reported as such already.
*/
function capOnly(term) {
const offScale =
/^[+\d.]/.test(term.value) &&
!WEIGHT_SCALE.includes(Number(term.value));
return Boolean(term.cap) && !offScale;
}
/** A string literal in code, whole: its quote and its text. */
const STRING_LITERAL = /^\s*(['"`])([\s\S]*)\1\s*$/;
/**
* The CSS text a value set from code can hold: the text of each string
* literal it can give (see `resultsOf`); any other result as written.
*/
function cssOfCode(expression) {
return resultsOf(expression).map(
(result) => STRING_LITERAL.exec(result)?.[2] ?? result
);
}
/** A JavaScript number literal (`0x28a`, `0o1212`, `6_50`, `650n`). */
const JS_NUMBER =
/(?<![\w$.])(?:0[xob][\da-f_]+|\d[\d_]*(?:\.[\d_]*)?(?:e[+-]?\d[\d_]*)?)n?(?![\w$.])/gi;
/**
* Code with each number literal spelled other than in decimal written as
* the number JavaScript makes of it (`0x28a` is `650`, also in a branch or
* an operand), so it reads as the weight a setter receives.
*/
function numberText(code) {
return code.replace(JS_NUMBER, (literal) => {
if (!/^0[xob]|_|n$/i.test(literal)) return literal;
const value = Number(literal.replace(/_|n$/gi, ''));
return Number.isFinite(value) ? String(value) : literal;
});
}
/** A literal's text as JavaScript puts it in a string, or `null`. */
function literalText(result) {
const string = STRING_LITERAL.exec(result);
if (string) return string[2].includes('${') ? null : string[2];
const number = Number(result.trim());
return Number.isFinite(number) ? String(number) : null;
}
/**
* The texts a template literal's body can produce when every `${…}` in it
* gives literals (`65${0}` is `650`, `${wide ? 650 : 600}` is `650` or
* `600`), up to 16 of them; `null` when one gives anything else.
*/
export function templateTexts(body) {
let texts = [''];
for (const part of templateParts(body)) {
const values =
part.code === undefined
? [part.text]
: resultsOf(part.code).map(literalText);
if (values.includes(null)) return null;
texts = texts.flatMap((text) => values.map((value) => text + value));
if (texts.length > 16) return null;
}
return texts;
}
/** Analyses merged into one. */
function merged(analyses) {
return {
terms: analyses.flatMap((analysis) => analysis.terms),
references: analyses.flatMap((analysis) => analysis.references),
};
}
/**
* A value set from code, read per result it can take (see `resultsOf`), so
* a condition's numbers are not weights. A string literal is CSS text and is
* read as such, a template as each text it can produce; anything else is an
* expression, where other numbers appear too (a weight is 100 or more) and
* arithmetic computes the value.
*/
function analyseCode(expression, mode = 'weight', after = 0, cap = null) {
// A shorthand that names JetBrains Mono meets its cap.
const css = (text) => {
const mono = mode === 'font' && rendersMono(text, { shorthand: true });
return analyse(mode, text, {
after,
cap: mono ? MONO_WEIGHT_CAP : cap,
});
};
return merged(
resultsOf(expression).map((result) => {
const literal = STRING_LITERAL.exec(result);
if (!literal) {
return analyse('weight', numberText(result), {
minimum: 100,
code: true,
cap,
});
}
const texts = literal[2].includes('${')
? templateTexts(literal[2])
: [literal[2]];
if (texts) return merged(texts.map(css));
// `${w}` alone is `w`. Around a value the scan cannot know, a
// shorthand's other tokens still read as CSS, while a weight is
// computed from it (`6${w}`).
const parts = templateParts(literal[2]);
if (parts.length === 1 && parts[0].code !== undefined) {
return analyseCode(parts[0].code, mode, after, cap);
}
if (mode === 'font') return css(literal[2]);
return {
terms: [{ value: result.trim(), computed: true }],
references: [],
};
})
);
}
/**
* Where each mixin or function a stylesheet defines is called in it
* (`@include name`, `name(`), skipping the definition itself, with the
* callable a call sits in (`within`), if any: such a call runs only when
* that callable does.
*/
function callSitesOf(text, blocks) {
const calls = {};
const names = blocks
.filter((block) => block.kind === 'callable' && block.name)
.map((block) => block.name);
for (const name of new Set(names)) {
// Sass reads `-` and `_` alike, so `heading_style` calls `heading-style`.
const escaped = name
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/-/g, '[-_]');
const call = new RegExp(
String.raw`@include\s+${escaped}(?![\w-])|(?<![\w$.-])${escaped}\s*\(`,
'g'
);
calls[name] = [...text.matchAll(call)]
.filter((match) => !namesCallable(text, match.index))
.map(({ index }) => ({
index,
within: placeOf(blocks, index).callable,
}));
}
return calls;
}
/**
* One file's weight declarations: off-scale findings, the custom properties
* and Sass variables its weights refer to, and every such variable it defines
* (checked later, once the whole workspace has named what it refers to).
* A keyframe that sets a font only while it runs is read both ways: its
* frames over the rule that runs it, and that rule after it.
*/
export function scanWeights(file, written) {
const running = scanPass(file, written, true);
if (!running.transient) return running;
const after = scanPass(file, written, false);
const keyOf = (item) => JSON.stringify(item);
const union = (a, b) => [
...new Map([...a, ...b].map((item) => [keyOf(item), item])).values(),
];
return {
...running,
findings: union(running.findings, after.findings),
deferred: union(running.deferred, after.deferred),
};
}
function scanPass(file, written, transient) {
// Read as the browser reads it (markup references and CSS escapes
// decoded); lines are the file's own.
const { text: source, origin } = decodeSource(file, written);
const lexed = lex(file, source);
const { text, quoteAt } = lexed;
const writtenLine = lineIndex(written);
const lineOf = origin ? (index) => writtenLine(origin[index]) : writtenLine;
const stylesheet = STYLESHEET.test(file);
const patterns = stylesheet
? [STYLESHEET_WEIGHT, FONT_SHORTHAND]
: [SOURCE_WEIGHT, FONT_SHORTHAND];
// In a stylesheet a string is content (`content: "…"`), never a rule.
const inString = (index) => stylesheet && quoteAt[index] !== '';
const findings = [];
const references = [];
const definitions = [];
let declarations = 0;
const blocks = stylesheet ? blocksOf(lexed) : [];
const blockAt = new Map(blocks.map((block) => [block.start, block]));
// The flow-control and conditional at-rule blocks around a position.
const guardsAt = (index) =>
blocks
.filter((block) => block.start < index && index < block.end)
.filter(
(block) =>
block.kind === 'flow' ||
CONDITIONAL_RULE.test(block.prelude)
)
.map((block) => block.start);
const record = (name, index, analysis) => {
declarations += 1;
for (const term of analysis.terms) {
findings.push({ file, line: lineOf(index), name, ...term });
}
const { scopes, inCallable, callable } = placeOf(blocks, index);
references.push(
...analysis.references.map((reference) => ({
...{ ...reference, file, index },
...{ scopes, inCallable, callable },
}))
);
};
// The character before `index`, whitespace skipped.
const charBefore = (index) => {
let before = index - 1;
while (before >= 0 && /\s/.test(text[before])) before -= 1;
return text[before] ?? '';
};
// This file's Sass variable declarations, read for interpolated
// selectors (see `selectorOf`), in source order.
let assignments = null;
const assignmentsOf = () =>
(assignments ??= [...text.matchAll(DEFINITION)]
.filter(
({ 1: name, index }) => name.startsWith('$') && !inString(index)
)
// A `$x: 1` right after `(` or `,` is an argument.
.filter(({ index }) => !['(', ','].includes(charBefore(index)))
.map((match) => ({
key: identity(match[1]),
index: match.index,
value: valueAfter(lexed, match.index + match[0].length).value,
place: placeOf(blocks, match.index),
})));
// The literal a Sass variable holds at `at`: its last declaration there,
// when that is a plain name or string set unconditionally in a scope
// around `at`; `null` when the scan cannot know (a module member, an
// `@if`, a `!default` / `!global` assignment, or one outside the mixin
// that `at` is in).
const literalAt = (name, at) => {
const key = identity(name);
const scopes = placeOf(blocks, at).scopes;
const before = assignmentsOf().filter(
(a) => a.key === key && a.index < at
);
if (before.some((a) => /!\s*(?:default|global)\b/i.test(a.value))) {
return null;
}
const last = before
.filter((a) => scopes.includes(a.place.scope))
.at(-1);
if (!last || last.place.flow) return null;
// In a mixin, only its own assignments are known where it is
// included; one outside may change before the `@include`.
const inside = placeOf(blocks, at);
if (inside.inCallable && last.place.callable !== inside.callable) {
return null;
}
const value = /^\s*(?:([\w-]+)|(['"])([^'"]*)\2)\s*$/.exec(last.value);
return value ? (value[1] ?? value[3]) : null;
};
// The selector chains of the blocks around a place, innermost first:
// one per selector of each list (`.a, .b { .x {} }` is `.x < .a` and
// `.x < .b`), and two rules with a chain in common style its elements.
// Spacing outside strings is the same selector; inside one
// (`[title="a b"]`) it is not. A Sass interpolation reads the literal
// its variable holds there (`$n: a; .#{$n}` is `.a`); one the scan cannot
// know, or more than 16 chains, makes the block's chain its own.
const selectorsOf = (scopes) => {
let chains = [''];
// Past an `@at-root`, the blocks it leaves out (see
// `atRootExcludes`) are not written out around it.
let excluded = () => false;
for (const scope of scopes) {
if (scope === null) continue;
const written = blockAt
.get(scope)
.prelude.replace(SPACING, (m) => (/^\s/.test(m) ? ' ' : m))
.replace(
/#\{\s*(\$[\w-]+)\s*\}/g,
(m, name) => literalAt(name, scope) ?? m
);
const excludes = atRootExcludes(written);
const prelude = excludes
? written.replace(/^@at-root\b\s*/i, '')
: written;
if (excludes) {
const outer = excluded;
excluded = (kind) => outer(kind) || excludes(kind);
if (!prelude || prelude.startsWith('(')) continue;
} else if (excluded(blockKind(prelude))) continue;
// Each unnamed `@layer { … }` is a layer of its own.
const parts = /^@layer\s*$/i.test(prelude)
? [`@layer @${scope}`]
: /^@|#\{/.test(prelude)
? [prelude]
: selectorList(prelude).map(canonicalSelector);
chains = chains.flatMap((chain) =>
parts.map((part) => (chain ? `${chain} < ${part}` : part))
);
}
const known = chains.length <= 16 && !chains.join().includes('#{');
return known ? chains : [`${chains.join(', ')} @ ${scopes.join(' ')}`];
};
// Blocks with a selector chain in common (and the same `@if` or `@each`
// around them) are one rule to the cascade for it: its declarations
// apply in source order, whichever block holds them. A block is in one
// rule per chain.
const rulesOf = (start) => {
if (start === null || start === undefined) return [];
const flow = blocks
.filter(
(b) => b.kind === 'flow' && b.start < start && start < b.end
)
.map((b) => b.start);
const chains = selectorsOf(placeOf(blocks, start + 1).scopes);
return chains.map((chain) => `${chain} | ${flow.join(' ')}`);
};
// The family each rule renders in (see `familiesOf`); one named through
// variables is resolved once the whole workspace is scanned.
const refsIn = (parts, index, place) =>
familyRefs(parts, {
...{ file, index, ...place, guards: guardsAt(index) },
rule: blockAt.get(place.scope)?.prelude ?? null,
selectors: selectorsOf(place.scopes),
});
const monoAt = stylesheet
? familiesOf(lexed, blocks, {
...{ inString, placeOf, refsIn, rulesOf, transient },
})
: Object.assign(() => ({ mono: false, refs: [] }), {
landings: () => [],
});
// Weights in rules whose family is named through variables: capped once
// that family resolves to JetBrains Mono.
const deferred = [];
// Each rule's weight declarations (`font-weight` and the `font`
// shorthand): a later one, unless only the earlier is `!important`,
// replaces it, so only the one in effect meets the cap.
const setters = new Map();
// A weight declaration registers in each rule it lands in (a mixin's
// where it is included), at the place it lands.
const setAt = (index, important) => {
// A keyframe's weight, where a rule runs it, outranks the rule's own.
for (const { rules, key, animated } of monoAt.landings(index)) {
for (const rule of rules) {
if (!setters.has(rule)) setters.set(rule, []);
const level = levelOf({ important, animated });
setters.get(rule).push({ key, index, level });
}
}
};
for (const match of stylesheet ? text.matchAll(WEIGHT_SETTER) : []) {
if (inString(match.index) || inConditionPrelude(lexed, match.index)) {
continue;
}
if (!startsDeclaration(text, match.index)) continue;
const start = match.index + match[0].length;
const { value, selector } = declarationText(lexed, start);
if (selector) continue;
if (match[1].toLowerCase() === 'font' && !parsesAsFont(value)) continue;
// A nested `font: { weight: … }` sets its rule's weight.
const namespace = fontNamespaceRule(
blocks,
placeOf(blocks, match.index)
);
if (match[1].toLowerCase() === 'weight' && namespace === undefined) {
continue;
}
setAt(match.index, IMPORTANT.test(value));
}
// An `all` reset replaces an earlier weight too.
for (const match of stylesheet ? text.matchAll(ALL_RESET) : []) {
if (inString(match.index) || inConditionPrelude(lexed, match.index)) {
continue;
}
if (startsDeclaration(text, match.index))
setAt(match.index, Boolean(match[2]));
}
// A shorthand that fails to parse is dropped, so it sets nothing.
// In a selector list, it is in effect while it is for any selector.
// A weight is in effect where, in a rule it lands in, nothing later
// replaces it and nothing earlier outranks it (`!important`): the
// scopes of those landings, the only ones whose family it meets.
const after = (a, b) => keyOrder(a.key, b.key) > 0;
const effectiveIn = (index) => {
const landings = monoAt.landings(index).filter(({ rules, key }) =>
rules.some((id) => {
const rule = setters.get(id) ?? [];
const own = rule.find(
(setter) =>
setter.index === index &&
keyOrder(setter.key, key) === 0
);
return (
Boolean(own) &&
!rule.some(
(other) =>
other !== own &&
(other.level > own.level ||
(other.level === own.level &&
after(other, own)))
)
);
})
);
return new Set(landings.map(({ scope }) => scope));
};
// CSS text in a string (an inline `style="…"`, a component style) meets
// the Mono cap when the declarations around it set JetBrains Mono.
const monoInString = (index) => {
const quote = quoteAt[index];
if (!quote) return false;
let start = index;
let end = index;
while (start > 0 && quoteAt[start - 1] === quote) start -= 1;
while (end < text.length && quoteAt[end] === quote) end += 1;
// The closing quote is not CSS.
if (text[end - 1] === quote) end -= 1;
const before = text.slice(start, index);
const after = text.slice(index, end);
const rule =
before.slice(
Math.max(before.lastIndexOf('{'), before.lastIndexOf('}')) + 1
) + after.slice(0, after.search(/[{}]|$/));
return familyInCss(rule);
};
// Whether a `return` at `at` belongs to a function nested in the body
// opened at `open` (an arrow, `function` or method), not to the body.
const inNestedFunction = (open, at) => {
const braces = [];
for (let i = open + 1; i < at; i += 1) {
if (quoteAt[i]) continue;
if (text[i] === '{') braces.push(i);
else if (text[i] === '}') braces.pop();
}
return braces.some((brace) => opensFunction(text, brace));
};
// A feature query's test is a condition, not a declaration.
const inPrelude = (index) => stylesheet && inConditionPrelude(lexed, index);
// In markup only CSS contexts style anything (see `inMarkupCss`).
const markup = /\.(?:html|svg)$/.test(file);
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
if (inString(match.index) || inPrelude(match.index)) continue;
if (markup && !inMarkupCss(lexed, match.index)) continue;
const name = match[1];
const end = match.index + match[0].length;
const read = valueAfter(lexed, end);
const { selector } = read;
// An unquoted attribute value ends at a space or `>`.
const unquoted =
markup &&
!quoteAt[match.index] &&
inUnquotedStyle(lexed, match.index);
const value = unquoted ? read.value.split(/[\s>]/)[0] : read.value;
if (selector) continue;
const mode = name.toLowerCase() === 'font' ? 'font' : 'weight';
// A TypeScript object value (`{ fontWeight: wide ? 700 : 600 }`),
// or one in an Angular binding (`[ngStyle]="{…}"`), is code, read
// per value it can take; a string is CSS text.
const code =
(file.endsWith('.ts') && !quoteAt[end]) || inBinding(text, end);
if (!stylesheet && code) {
const expression = codeExpression(text, end, {
argument: true,
});
record(name, match.index, analyseCode(expression, mode));
continue;
}
const nested =
/^weight$/i.test(name) &&
fontNamespaceRule(blocks, placeOf(blocks, match.index)) !==
undefined;
const weightName = /^font(?:-weight)?$/i.test(name) || nested;
const effective = weightName ? effectiveIn(match.index) : new Set();
const family =
effective.size > 0
? monoAt(match.index, (scope) => effective.has(scope))
: { mono: false, refs: [] };
const inline =
!stylesheet && weightName && monoInString(match.index);
const cap = family.mono || inline ? MONO_WEIGHT_CAP : null;
// CSS text in a string of code ends with that string; a value
// left to code (`'font-weight:' + 650`) is the operand after `+`.
const literal = stylesheet ? null : codeStringAt(lexed, file, end);
const css = literal ? value.slice(0, literal.close - end) : value;
const operand =
literal && /^\s*$/.test(css)
? concatenatedAfter(text, literal)
: null;
if (operand !== null) {
record(
name,
match.index,
analyseCode(operand, 'weight', 0, cap)
);
continue;
}
// A template's CSS reads as each text it can produce.
const template = !stylesheet && css.includes('${');
const texts = template ? templateTexts(css) : null;
const cssOf = (text) => analyse(mode, text, { cap });
record(name, match.index, merged((texts ?? [css]).map(cssOf)));
if (!family.mono && family.refs.length > 0) {
const capped = analyse(mode, value, { cap: MONO_WEIGHT_CAP });
const place = placeOf(blocks, match.index);
deferred.push({
...{ file, line: lineOf(match.index), name },
family: family.text,
shorthand: family.shorthand,
at: {
...{ ...family.at, file },
guards: guardsAt(family.at.index),
rule: blockAt.get(family.at.scope)?.prelude ?? null,
selectors: selectorsOf(family.at.scopes ?? []),
},
terms: capped.terms.filter(capOnly),
references: capped.references.map((reference) => ({
...{ ...reference, file, index: match.index },
...{ scopes: place.scopes, callable: place.callable },
inCallable: place.inCallable,
})),
});
}
}
}
// A weight set from code is checked here; any other custom property it
// sets is a definition that a stylesheet's `var()` may refer to.
const setByCode = (name, property, index, expression) => {
if (/^font-?weight$|^--.*weight$/i.test(property)) {
record(name, index, analyseCode(expression));
} else if (/^font$/i.test(property)) {
record(name, index, analyseCode(expression, 'font'));
} else if (property.startsWith('--')) {
const line = lineOf(index);
const value = expression;
const key = property;
definitions.push({
file,
line,
name: property,
key,
value,
code: true,
});
}
};
if (!stylesheet) {
for (const match of text.matchAll(ATTRIBUTE_WEIGHT)) {
// An attribute sits inside a tag; text such as
// `<p>font-weight=750</p>` sets nothing.
const html = /\.(?:html|svg)$/.test(file);
if (!insideTag(lexed, match.index, { html })) continue;
const value = match[3] ?? match[4];
record(match[1], match.index, analyse('weight', value));
}
for (const match of text.matchAll(CODE_BINDING)) {
setByCode(match[1], match[2], match.index, match[4]);
}
for (const match of text.matchAll(HOST_BINDING)) {
const [label, , target, property] = match;
if (target === 'attr' && property !== 'font-weight') continue;
const end = match.index + label.length;
// A getter's value is whatever any `return` in its body gives.
const getter = HOST_GETTER.exec(text.slice(end));
if (getter) {
const open = end + getter[0].length - 1;
const body = text.slice(open, closingBrace(lexed, open));
for (const statement of body.matchAll(/\breturn\b/g)) {
const at = open + statement.index;
if (quoteAt[at] || inNestedFunction(open, at)) continue;
const expression = codeExpression(text, at + 6);
setByCode(label, property, match.index, expression);
}
continue;
}
const field = HOST_FIELD.exec(text.slice(end));
if (!field) continue;
const expression = codeExpression(text, end + field[0].length);
setByCode(label, property, match.index, expression);
}
for (const match of text.matchAll(CODE_ASSIGNMENT)) {
const end = match.index + match[0].length;
const expression = codeExpression(text, end).trim();
const operator = match[5];
const shorthand = (match[2] ?? match[4]) === 'font';
if (operator && !LOGICAL_ASSIGNMENT.test(operator)) {
const value = `${operator}= ${expression}`;
const terms = [{ value, computed: true }];
record(match[1], match.index, { terms, references: [] });
} else if (shorthand) {
record(match[1], match.index, analyseCode(expression, 'font'));
} else {
setByCode(match[1], 'font-weight', match.index, expression);
}
}
for (const match of text.matchAll(CODE_SETTER)) {
const { skip, property } = SETTERS[match[1]];
let at = match.index + match[0].length;
for (let k = 0; k < skip && at < text.length; k += 1) {
at += codeExpression(text, at, { argument: true }).length + 1;
}
const quoted = QUOTED_NAME.exec(text.slice(at));
if (!quoted || !property.test(quoted[3])) continue;
const value = at + quoted[0].length;
const expression = codeExpression(text, value, { argument: true });
const name = text
.slice(match.index, at + quoted[1].length)
.replace(/\s+/g, '');
setByCode(name, quoted[3], match.index, expression);
}
}
for (const match of text.matchAll(DEFINITION)) {
const name = match[1];
if (inString(match.index) || inPrelude(match.index)) continue;
if (!stylesheet && name.startsWith('$')) continue;
const end = match.index + match[0].length;
const { value, selector } = valueAfter(lexed, end);
if (selector) continue;
// `$x: 1` right after `(` or `,` is an argument (a mixin call, a
// `with (…)` configuration); otherwise it declares the variable.
const argument = ['(', ','].includes(charBefore(match.index));
const line = lineOf(match.index);
const key = identity(name);
const index = match.index;
// A declaration belongs to its innermost scope (see `placeOf`); a
// `!global` one assigns the module variable, whenever it runs.
const place = placeOf(blocks, index);
const global = /!global\b/i.test(value);
// `!default` assigns only while the variable is unset (see
// `effectiveDeclarations`).
const fallback = /!default\b/i.test(value);
const full = argument ? value : declarationText(lexed, end).value;
definitions.push({
...{ file, line, index, name, key, value, argument, fallback },
// The whole declaration, for a family list (`a, b`).
full,
important: IMPORTANT.test(full),
// The selector of the rule it sits in, for custom properties.
rule: blockAt.get(place.scope)?.prelude ?? null,
selectors: selectorsOf(place.scopes),
// The rules it belongs to in the cascade (see `rulesOf`).
cascades: rulesOf(place.scope),
guards: guardsAt(index),
// Checked against the scale where it is declared (see below).
weighted: /weight$/i.test(name),
// An argument reaches only the mixin or function it is passed to.
callee: argument ? calleeOf(text, index) : null,
scope: global ? null : place.scope,
// A `!global` assignment runs in text order, unless flow
// control or a callable body (run when called) encloses it.
conditional: global
? place.flow || place.inCallable
: place.conditional,
...{ scopes: place.scopes, inCallable: place.inCallable },
callable: place.callable,
});
}
// `@property --x { initial-value: … }` gives a registered property its
// value wherever nothing sets it; a `…weight` name is checked there.
for (const match of stylesheet ? text.matchAll(PROPERTY_RULE) : []) {
if (inString(match.index)) continue;
const brace = match.index + match[0].length - 1;
const body = text.slice(brace, blockAt.get(brace)?.end ?? brace);
const initial = /(?<![\w-])initial-value\s*:\s*([^;}]*)/i.exec(body);
if (!initial) continue;
const index = brace + initial.index;
const name = match[1];
const value = initial[1].trim();
// CSS ignores an invalid registration.
if (!registrationAccepts(body, value)) continue;
const weighted = /weight$/i.test(name);
if (weighted) record(name, index, analyse('weight', value));
const place = placeOf(blocks, match.index);
definitions.push({
...{ file, line: lineOf(index), index, name, key: name, value },
...{ full: value, argument: false, fallback: false, weighted },
...{ important: false, callee: null, registered: true },
// `inherits: false` gives each element the initial value.
inherits: /(?<![\w-])inherits\s*:\s*true\b/i.test(body),
...{ rule: null, guards: guardsAt(match.index), scope: null },
...{ conditional: false, scopes: place.scopes },
...{ inCallable: place.inCallable, callable: place.callable },
});
}
// Loop variables take every value of their list (or range), and only
// inside the loop body (`loop`), where they shadow an outer namesake.
const loopVariable = (name, header, value, range = null) => {
const index = header.index;
const brace = index + header[0].length - 1;
const body = blockAt.get(brace);
const place = placeOf(blocks, index);
definitions.push({
...{ file, line: lineOf(index), index, name, key: identity(name) },
...{ value, full: value, argument: false, fallback: false },
...{ important: false, weighted: false, callee: null, range },
rule: blockAt.get(place.scope)?.prelude ?? null,
...{ guards: guardsAt(index), scope: place.scope },
...{ conditional: false, scopes: place.scopes },
...{ inCallable: place.inCallable, callable: place.callable },
loop: { start: brace, end: body?.end ?? brace },
});
};
for (const match of stylesheet ? text.matchAll(EACH_LOOP) : []) {
if (inString(match.index)) continue;
const names = match[1].split(',').map((name) => name.trim());
const columns = loopColumns(match[2].trim(), names.length);
names.forEach((name, i) => loopVariable(name, match, columns[i]));
}
for (const match of stylesheet ? text.matchAll(FOR_LOOP) : []) {
if (inString(match.index)) continue;
const [, name, from, bound, to] = match;
const value = `from ${from.trim()} ${bound} ${to.trim()}`;
const range = {
from: from.trim(),
to: to.trim(),
through: bound === 'through',
};
loopVariable(name, match, value, range);
}
// Sass can build a property name; one that composes to a weight name
// (from literals, or this file's variables as they stand there) is
// checked like one.
const valuesAt = (variable, index) => {
const place = placeOf(blocks, index);
const key = identity(variable);
const candidates = definitions.filter(
(d) => d.key === key && !d.argument && inLoopOf(d, index)
);
// A loop variable takes each item of its list in turn.
return effectiveDeclarations(
{ index, ...place },
candidates
).picked.flatMap((d) =>
d.loop
? d.value
.replace(/^\((.*)\)$/s, '$1')
.split(',')
.map(sassValue)
: [sassValue(d.value)]
);
};
// A name whose own tail ends in `weight` is checked as written above.
for (const match of stylesheet ? text.matchAll(INTERPOLATED_NAME) : []) {
if (inString(match.index) || inPrelude(match.index)) continue;
if (/weight$/i.test(match[1])) continue;
// A name that can compose to several (`font` or `font-weight`) is
// read every way it can.
const modes = new Set(
composedNames(match[1], (variable) =>
valuesAt(variable, match.index)
)
.filter((candidate) => WEIGHT_NAME.test(candidate))
.map((candidate) =>
candidate.toLowerCase() === 'font' ? 'font' : 'weight'
)
);
if (modes.size === 0) continue;
const end = match.index + match[0].length;
const { value, selector } = valueAfter(lexed, end);
if (selector) continue;
const analyses = [...modes].map((mode) => analyse(mode, value));
const terms = new Map(
analyses
.flatMap((analysis) => analysis.terms)
.map((term) => [JSON.stringify(term), term])
);
record(match[1], match.index, {
terms: [...terms.values()],
references: analyses.flatMap((analysis) => analysis.references),
});
}
const loads = stylesheet ? extractStylesheetLoads(source) : [];
const calls = callSitesOf(text, blocks);
const invocations = stylesheet ? invocationsOf(lexed) : [];
return {
...{ file, loads, declarations, findings, references, definitions },
...{ calls, invocations, deferred },
transient: Boolean(monoAt.transient),
};
}
/**
* Definitions of the variables that weight declarations refer to, read the
* way they are used (a weight or a whole `font` shorthand) and followed
* through chains (`--a: var(--b)`). A name ending in `weight` is already
* checked against the scale where it is declared, so only a JetBrains Mono
* rule's cap is new there. Custom properties cascade across the app, so
* any definition counts; a Sass variable only what its module scope sees
* (see `sassScopes`).
*/
export function findIndirectWeights(scans) {
const definitions = scans.flatMap((scan) => scan.definitions);
const pending = scans.flatMap((scan) => scan.references);
const { qualified, unqualified, imports, loadsOf } = sassScopes(scans);
const callsByFile = new Map(scans.map((scan) => [scan.file, scan.calls]));
// Whether a declaration is what `name` reads, through one of the ways
// `access` exposes its file (a `@forward` prefix, `show`/`hide`).
const exposes = (access, definition, name) =>
Boolean(
access?.exposures.some(
(exposure) => exposedName(exposure, definition.key) === name
)
);
// Whether an argument sits in a `with (…)` that `access` counts and sets
// `name` there: the names written in it read through the range's
// exposure, or, for a loader configuring this module, the other way.
const configures = (access, definition, name) =>
Boolean(
access?.ranges.some(
({ start, end, exposure, outward }) =>
definition.index >= start &&
definition.index < end &&
(outward
? exposedName(exposure, name) === definition.key
: exposedName(exposure, definition.key) === name)
)
);
// Whether an argument is passed to `callable`, defined in `file`:
// `ns.name(` must load `file` (or a module forwarding it) as `ns`, under
// the name it exposes `callable` by; a bare `name(` is defined in the
// caller itself or in what it brings in.
const passedTo = ({ callee, file: caller }, file, callable) => {
if (!callee || !callable) return false;
if (!callee.namespace && caller === file) {
return callee.name === callable;
}
const scope = callee.namespace
? qualified(caller, callee.namespace)
: unqualified(caller);
const access = scope.get(file);
return (
Boolean(access?.declarations) &&
access.exposures.some(
(exposure) => exposedName(exposure, callable) === callee.name
)
);
};
// A parameter default is the value only at calls that leave it out. A
// callable with no call in sight may be called from where the scan
// cannot see, so its defaults count.
const invocations = scans.flatMap(({ file, invocations: calls = [] }) =>
calls.map((call) => ({ ...call, file }))
);
const defaultUsed = new Map();
const usesDefault = (definition) => {
if (defaultUsed.has(definition)) return defaultUsed.get(definition);
const callable = definition.callee.name;
const calls = invocations.filter(
(call) =>
call.callee.name.endsWith(callable) &&
passedTo(call, definition.file, callable)
);
const names = (call) =>
definitions.some(
(d) =>
d.argument &&
d.file === call.file &&
d.callee?.paren === call.paren &&
d.key === definition.key
);
const used = calls.length === 0 || calls.some((call) => !names(call));
defaultUsed.set(definition, used);
return used;
};
// A partial runs only where it is loaded, so its `!default` for a name
// never applies when every load sets that name (not to `null`), in its
// own `with (…)` or, through a `@forward`, in the forwarding module's
// loads under the prefix. A file that is not a partial may be compiled
// on its own, unconfigured.
const configuredCache = new Map();
const alwaysConfigured = (file, name, chain = new Set()) => {
const cacheKey = `${file} ${name}`;
if (chain.size === 0 && configuredCache.has(cacheKey)) {
return configuredCache.get(cacheKey);
}
const partial = /^_/.test(path.posix.basename(file));
if (!partial || chain.has(file)) return false;
const loads = loadsOf(file);
const deeper = new Set([...chain, file]);
const sets = (load) =>
definitions.some(
(d) =>
d.argument &&
d.file === load.file &&
d.key === name &&
!/^null\b/i.test(d.value.trim()) &&
load.ranges.some(([s, e]) => d.index >= s && d.index < e)
);
const configured =
loads.length > 0 &&
loads.every(
(load) =>
sets(load) ||
(load.forward &&
alwaysConfigured(
load.file,
exposedName(
{ prefix: load.prefix, filters: [] },
name
),
deeper
))
);
if (chain.size === 0) configuredCache.set(cacheKey, configured);
return configured;
};
// `@import` is textual: an imported file's top-level declarations take
// effect where the `@import` sits, transitively.
// Each `@import` runs its file again, so only the current path guards
// against cycles.
// `order` keeps the text order inside the inclusion: the importer's
// position, then each imported file's own positions.
const imported = (file, name, prefix = [], path = new Set([file])) =>
imports(file).flatMap(({ loaded, index }) => {
if (path.has(loaded)) return [];
const order = [...prefix, index];
const position = order[0];
const own = definitions
.filter((d) => d.file === loaded && d.key === name)
.filter((d) => !d.argument && d.scope === null)
.map((d) => ({
...d,
index: position,
order: [...order, d.index],
original: d,
}));
const deeper = new Set([...path, loaded]);
return [...own, ...imported(loaded, name, order, deeper)];
});
// Where a callable runs: its call sites, with a call inside another
// callable's body replaced by where that one runs.
const runsAt = (file, name, seen = new Set()) => {
if (!name || seen.has(name)) return [];
seen.add(name);
return (callsByFile.get(file)?.[name] ?? []).flatMap(
({ index, within }) =>
within ? runsAt(file, within, seen) : [index]
);
};
// A custom property declared again, unconditionally, later in the same
// rule (any block with its selector, see `rulesOf`) is replaced there,
// unless only the earlier one is `!important`; in a selector list, only
// where that holds for every selector.
const byRule = new Map();
for (const definition of definitions) {
const { key, argument, cascades, code } = definition;
if (!key.startsWith('--') || argument || code || !cascades?.length) {
continue;
}
// Its rules and selector chains line up (see `rulesOf`).
cascades.forEach((rule, i) => {
const group = `${definition.file} ${rule} ${key}`;
if (!byRule.has(group)) byRule.set(group, []);
byRule
.get(group)
.push({ definition, chain: definition.selectors[i] });
});
}
// The selector chains each definition is replaced for.
const overridden = new Map();
for (const group of byRule.values()) {
for (const { definition, chain } of group) {
const later = group.some(
({ definition: other }) =>
other.index > definition.index &&
!other.conditional &&
(other.important || !definition.important)
);
if (!later) continue;
if (!overridden.has(definition))
overridden.set(definition, new Set());
overridden.get(definition).add(chain);
}
}
const replacedFor = (definition, chains) =>
chains.every((chain) => overridden.get(definition)?.has(chain));
const replaced = new Set(
[...overridden.keys()].filter((definition) =>
replacedFor(definition, definition.selectors)
)
);
// The definitions a reference can resolve to, as Sass and the cascade
// read them (see `sassScopes` and `effectiveDeclarations`).
const visibleDefinitions = (reference) => {
const { name, file, namespace } = reference;
const sass = name.startsWith('$');
const members = sass && namespace ? qualified(file, namespace) : null;
const scope = sass && !namespace ? unqualified(file) : null;
// A `with (…)` of this very lookup sets the name, so the module's
// `!default` for it never applies here.
// `null` counts as unset, so the `!default` still applies.
const configuredHere = definitions.some(
(d) =>
d.argument &&
d.file === file &&
!/^null\b/i.test(d.value.trim()) &&
configures((members ?? scope)?.get(file), d, name)
);
// In its own file, only the declarations in effect at the reference
// count; other files' declarations only when those settle nothing.
const own = scope
? effectiveDeclarations(
reference,
[
...definitions.filter(
(d) =>
d.key === name &&
d.file === file &&
!d.argument &&
inLoopOf(d, reference.index)
),
...imported(file, name),
],
runsAt(file, reference.callable)
)
: null;
const picked = new Set(own?.picked.map((d) => d.original ?? d));
return definitions.filter((definition) => {
const access = (members ?? scope)?.get(definition.file);
if (!sass && definition.key !== name) return false;
// A loop variable exists only inside its loop, in its file.
if (definition.loop) {
const here = definition.file === file;
if (!here || !inLoopOf(definition, reference.index))
return false;
}
if (!sass && replaced.has(definition)) return false;
// An argument's name is matched where it is passed, below.
if (sass && !definition.argument) {
if (!exposes(access, definition, name)) return false;
}
if (
configuredHere &&
definition.fallback &&
!definition.argument &&
definition.file !== file
) {
return false;
}
if (
sass &&
definition.fallback &&
!definition.argument &&
definition.scope === null &&
alwaysConfigured(definition.file, definition.key)
) {
return false;
}
if (sass && !definition.argument && !picked.has(definition)) {
if (definition.file === file && !members) return false;
// Other modules see only top-level (or `!global`) members.
if (definition.scope !== null || own?.settled) return false;
}
if (members) {
const visible = definition.argument
? configures(access, definition, name)
: access?.declarations;
if (!visible) return false;
}
if (scope) {
const passed =
definition.key === name &&
passedTo(definition, file, reference.callable) &&
(!definition.callee.signature || usesDefault(definition));
const configured = configures(access, definition, name);
// A textual importer's later code has not run when this
// file's rules render, unless they sit in a mixin body.
const ran =
reference.inCallable ||
definition.index < (access?.before ?? Infinity);
const visible = definition.argument
? access?.arguments && (passed || configured)
: access?.declarations && ran;
if (!visible) return false;
}
return true;
});
};
// Whether a definition sets a custom property for every element the
// reading rule styles: one in a rule every element inherits from
// (`:root`), or in the reading rule itself, one with the same selector,
// or a rule it is nested in (or its component's `:host`). A property set by code sits in another file
// and is set on some element only.
const setsFor = (definition, reference) => {
// A registered property always has a value: its `initial-value`.
if (definition.registered) return true;
const own = definition.file === reference.file;
// A condition (`@media`, `@if`, …) around the definition that does
// not also hold around the reading declaration may leave it unset.
const shared = own ? (reference.guards ?? []) : [];
if ((definition.guards ?? []).some((g) => !shared.includes(g))) {
return false;
}
if (own && (reference.scopes ?? []).includes(definition.scope)) {
return true;
}
// The same selector written again in the file styles the same
// elements (another file's component styles reach other ones).
if (own && covers(definition.selectors, reference.selectors)) {
return true;
}
if (reachesEverything(definition.rule, reference.rule)) return true;
return own && plainHost(definition.rule);
};
// Whether a custom property can be unset where it is read, so a `var()`
// fallback applies: no rule sets it (one that inherits sets nothing of
// its own), one resets it, or one sets it to a value that can be invalid.
// A property that reads itself, directly or through others
// (`--face: var(--face)`), is in a cycle, which CSS makes invalid.
const mayBeUnset = (reference, path = new Set()) => {
const key = `${reference.file} ${reference.index} ${reference.name}`;
if (path.has(key)) return true;
path.add(key);
const own = visibleDefinitions(reference)
.map((definition) => ({
definition,
text: (definition.full ?? definition.value).trim(),
}))
.filter(({ text }) => !INHERITING.test(text));
const unset =
!own.some(({ definition }) => setsFor(definition, reference)) ||
own.some(
({ definition, text }) =>
RESETTING.test(text) || mayBeInvalid(text, definition, path)
);
path.delete(key);
return unset;
};
// A value is invalid when a `var()` in it reads a property that can be
// unset and its own fallback (if any) can be invalid too.
const mayBeInvalid = (text, at, path) =>
familyRefs(familyParts(text), at).some(
(ref) =>
mayBeUnset(ref, path) &&
(ref.fallback === null || mayBeInvalid(ref.fallback, at, path))
);
// How a family list (or one variable's lists) decides JetBrains Mono,
// entry by entry in its order: `mono`, `stop` (a family that renders
// every glyph comes first) or `open`. A variable stands for each list
// it can hold (its `var()` fallback too where it can be unset); any
// branch that renders Mono counts.
const combine = (verdicts) => {
if (verdicts.includes('mono')) return 'mono';
const decided = verdicts.length > 0;
return decided && verdicts.every((v) => v === 'stop') ? 'stop' : 'open';
};
// A custom property set on the reading rule's own elements: by a rule
// with its selector in its file (see `selectorOf`), under no condition
// the reader does not share, to a value of its own. It replaces what
// those elements inherit.
const setsOwn = (definition, reference, chains = reference.selectors) =>
definition.file === reference.file &&
covers(definition.selectors, chains) &&
!INHERITING.test((definition.full ?? definition.value).trim()) &&
(definition.guards ?? []).every((guard) =>
(reference.guards ?? []).includes(guard)
);
// How near the reading rule's elements a definition sets a custom
// property: a registered initial value lowest (it applies where nothing
// is set), then `:root`, `body`, an enclosing rule in the reader's file
// (the innermost nearest) and `*` on the element itself; `null` for one
// whose place relative to them is unknown (another rule, code).
const nearness = (definition, reference) => {
if (definition.registered) return 0;
const depth = reachDepth(definition.rule, reference.rule);
if (depth !== null) return depth;
const own = definition.file === reference.file;
const at = own
? (reference.scopes ?? []).indexOf(definition.scope)
: -1;
return at > 0 ? 3 - at / 1000 : null;
};
// The definitions that can still apply once the nearest one set
// unconditionally to a value of its own hides the farther ones.
// A property registered with `inherits: false` reaches no element from
// an ancestor (`:root`, `body`, an enclosing rule): only what the element
// sets itself (`*`, code) or the initial value.
const nearest = (visible, reference) => {
// An explicit `inherit` on the reader's own rule still takes the
// parent's value (`unset` and `revert` give the initial one).
const inherited = visible.some(
(d) =>
d.file === reference.file &&
covers(d.selectors, reference.selectors) &&
/^inherit\b/i.test((d.full ?? d.value).trim())
);
const local =
!inherited && visible.some((d) => d.registered && !d.inherits);
const ranks = visible.map((d) => nearness(d, reference));
if (local) {
const ancestor = (rank) => rank !== null && rank > 0 && rank < 4;
return visible.filter((_, i) => !ancestor(ranks[i]));
}
const settled = visible
.map((definition, i) => ({ definition, rank: ranks[i] }))
.filter(({ definition, rank }) => {
const text = (definition.full ?? definition.value).trim();
const shared = (definition.guards ?? []).every(
(guard) =>
definition.file === reference.file &&
(reference.guards ?? []).includes(guard)
);
return rank !== null && shared && !INHERITING.test(text);
});
const top = Math.max(-1, ...settled.map(({ rank }) => rank));
return visible.filter((_, i) => ranks[i] === null || ranks[i] >= top);
};
const refVerdict = (reference, seen) => {
const { name, file, namespace, index, shorthand } = reference;
const key = `${file} ${namespace ?? ''} ${index} ${name} ${shorthand}`;
if (seen.has(key)) return 'open';
seen.add(key);
// A value the elements set themselves hides inherited ones, and of
// those the nearest settled one hides the farther (see `nearest`);
// one set from code (an inline style) may still win over either.
const visible = visibleDefinitions(reference);
let definitions = visible;
if (name.startsWith('--')) {
// Each of the reader's selectors (`.x, .y`) is its own elements:
// a value one sets there, and not replaced there, hides what
// those inherit, while one with none of its own inherits.
const chains = reference.selectors ?? [];
const ownFor = chains.map((chain) =>
visible.filter(
(definition) =>
setsOwn(definition, reference, [chain]) &&
!replacedFor(definition, [chain])
)
);
const inherited =
!chains.length || ownFor.some((own) => !own.length)
? nearest(visible, reference)
: [];
const kept = new Set([...ownFor.flat(), ...inherited]);
definitions = visible.filter((d) => d.code || kept.has(d));
}
// A value set from code holds the text of its strings.
const verdicts = definitions.flatMap((definition) =>
(definition.code
? cssOfCode(definition.value)
: [definition.full ?? definition.value]
).map((text) => familyVerdict(text, definition, seen, shorthand))
);
if (reference.fallback !== null && mayBeUnset(reference)) {
verdicts.push(
familyVerdict(reference.fallback, reference, seen, shorthand)
);
}
return combine(verdicts);
};
const familyVerdict = (text, at, seen, shorthand = false) => {
const parts = familyParts(text);
// A shorthand that is all variables: each holds a whole shorthand.
if (shorthand && !hasLiteralSize(parts.outside)) {
const list = shorthandFamilies(parts.outside);
if (list === null || /^\s*$/.test(list)) {
const refs = familyRefs(parts, { ...at, shorthand: true });
return combine(refs.map((ref) => refVerdict(ref, seen)));
}
}
const list = shorthand ? shorthandFamilies(text) : text;
if (list === null) return 'open';
for (const entry of familyEntries(list)) {
const verdict =
entryVerdict(entry) ?? variableVerdict(entry, at, seen);
if (verdict !== 'open') return verdict;
}
return 'open';
};
// A variable entry: the lists its variables hold, or that Sass composes
// from it (`'#{$prefix} Mono'`, `$a $b`).
const variableVerdict = (entry, at, seen) => {
const composed = /\$/.test(entry)
? composedFamilies(entry, at).map((t) => familyVerdict(t, at, seen))
: [];
const refs = familyRefs(familyParts(entry), {
...at,
shorthand: false,
});
return combine([
...composed,
...refs.map((ref) => refVerdict(ref, seen)),
]);
};
const familyIsMono = (text, at, seen, shorthand = false) =>
familyVerdict(text, at, seen, shorthand) === 'mono';
// A family Sass assembles from variables (`'#{$prefix} Mono'`, `$a $b`),
// with each variable replaced by a value it can hold (up to 16
// combinations, through chains of any length, a cycle aside), so the
// name is read whole. One the scan cannot resolve (a package module)
// leaves nothing to compose.
const composedFamilies = (text, at, path = new Set()) => {
let results = [''];
let last = 0;
for (const match of text.matchAll(SASS_VALUE)) {
const full = match[1] ?? match[2];
const dot = full.lastIndexOf('.');
const reference = {
name: identity(full.slice(dot + 1)),
namespace: dot === -1 ? null : full.slice(0, dot),
...{ file: at.file, index: at.index, scopes: at.scopes },
...{ inCallable: at.inCallable, callable: at.callable },
};
const values = visibleDefinitions(reference)
.filter((definition) => !path.has(definition))
.flatMap((definition) =>
composedFamilies(
sassValue(definition.full ?? definition.value),
definition,
new Set([...path, definition])
)
);
const between = text.slice(last, match.index);
results = results
.flatMap((result) => values.map((v) => result + between + v))
.slice(0, 16);
last = match.index + match[0].length;
}
return results.map((result) => result + text.slice(last));
};
const followed = new Set();
const findings = [];
for (const candidate of scans.flatMap((scan) => scan.deferred ?? [])) {
const { family, at, shorthand } = candidate;
if (!familyIsMono(family, at, new Set(), shorthand)) continue;
const { file, line, name } = candidate;
findings.push(
...candidate.terms.map((term) => ({ file, line, name, ...term }))
);
pending.push(...candidate.references);
}
while (pending.length > 0) {
const reference = pending.pop();
const { name, mode, after = 0, file, namespace, index } = reference;
const { cap = null } = reference;
const sass = name.startsWith('$');
// What a Sass name resolves to depends on where it is read.
const origin = sass ? `${file} ${namespace ?? ''} ${index}` : '';
const key = `${mode} ${after} ${cap} ${origin} ${name}`;
if (followed.has(key)) continue;
followed.add(key);
for (const definition of visibleDefinitions(reference)) {
// A declaration's whole value (a list `400, 650` too); an
// argument's ends at its comma.
const value = definition.argument
? definition.value
: (definition.full ?? definition.value);
// A shorthand that names its own family meets the Mono cap only
// when that family can render Mono (`--f: 700 16px Roboto` read
// where a fallback is Mono keeps its 700).
const own = mode === 'font' ? shorthandFamilies(value) : null;
const capHere =
own && !rendersMono(own) && !/var\(|\$/.test(own) ? null : cap;
const analysis = definition.range
? rangeAnalysis(definition)
: definition.code
? analyseCode(definition.value, mode, after, capHere)
: analyse(mode, value, { after, cap: capHere });
const terms = definition.weighted
? analysis.terms.filter(capOnly)
: analysis.terms;
for (const term of terms) {
const { line } = definition;
const at = {
file: definition.file,
line,
name: definition.name,
};
findings.push({ ...at, ...term });
}
pending.push(
...analysis.references.map((next) => ({
...next,
file: definition.file,
index: definition.index,
scopes: definition.scopes,
inCallable: definition.inCallable,
callable: definition.callable,
}))
);
}
}
return findings;
}
/** Every off-scale weight one file can reach on its own. */
export function findOffScaleWeights(file, source) {
const scan = scanWeights(file, source);
return {
declarations: scan.declarations,
findings: [...scan.findings, ...findIndirectWeights([scan])],
};
}
export function describeFinding(finding) {
const { file, line, name, value, computed, cap } = finding;
const scale = WEIGHT_SCALE.join('/');
if (cap && !computed) {
return `${file}:${line} ${name}: ${value} is heavier than ${cap}, the heaviest JetBrains Mono face bundled, and a JetBrains Mono rule uses it, so Chromium fakes the bold. Use ${cap}.`;
}
if (computed) {
return `${file}:${line} ${name}: ${value} is computed, and the compiled value is what renders (Sass turns \`400 + 500\` into 900). Write a ${scale} weight.`;
}
if (/^(bolder|lighter)$/i.test(String(value))) {
return `${file}:${line} ${name}: ${value} is relative to the parent weight and can land off the ${scale} scale. Use an explicit scale weight.`;
}
return `${file}:${line} ${name}: ${value} is off the ${scale} scale, so the bundled faces render it as a neighbouring weight or a synthetic bold. Use ${nearestScaleWeight(Number(value))}.`;
}
/**
* A check that scanned nothing must never report success: the workspace always
* contains stylesheets, so an empty listing means the file scan broke.
*/
export function validateScanCoverage(files) {
if (files.some((file) => STYLESHEET.test(file))) return [];
return [
'No stylesheets were scanned. The workspace always contains SCSS, so an empty listing means the file scan failed rather than that the policy passed.',
];
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
// No shell and no quoted pathspec: `cmd.exe` would pass quotes literally.
const files = execFileSync('git', ['ls-files', '--', 'apps', 'libs'], {
cwd: rootDir,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
.split('\n')
.filter(Boolean)
.filter(isScannedFile);
const scans = [];
for (const file of files) {
const source = await readFile(path.resolve(rootDir, file), 'utf8');
scans.push(scanWeights(file, source));
}
const findings = [
...scans.flatMap((scan) => scan.findings),
...findIndirectWeights(scans),
];
const diagnostics = [
...validateScanCoverage(files),
...findings.map(describeFinding),
];
const declarations = scans.reduce(
(sum, scan) => sum + scan.declarations,
0
);
if (diagnostics.length > 0) {
console.error('Font weight scale check failed:');
for (const diagnostic of diagnostics) console.error(`- ${diagnostic}`);
process.exitCode = 1;
} else {
console.log(
`Checked ${declarations} weight declarations across ${files.length} files; every weight is on the ${WEIGHT_SCALE.join('/')} scale and JetBrains Mono rules stay at ${MONO_WEIGHT_CAP} or lighter.`
);
}
}