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>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-04 08:55:23 +02:00
1 parent ab8460338a
commit e8b181fcea
52 files changed
+11216 -97

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+120 -22
View File
@@ -4,7 +4,14 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const STYLESHEET_RULE = /@(use|forward|import)\s+([^;{}]*)/g;
const STYLESHEET_RULE = /@(use|forward|import)\s+/g;
const OPEN_URL = /url\(\s*[^\s)'"]*$/i;
/** Whether `index` sits in an unquoted `url(…)` on its line. */
function inUrl(source, index) {
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
return OPEN_URL.test(source.slice(lineStart, index));
}
const QUOTED_TARGET = /(['"])([^'"]+)\1/g;
const CSS_URL = /url\([^)]*\)/g;
@@ -27,32 +34,123 @@ function targetsOfRule(rule, clause) {
/**
* Sass documents relative `@use` examples inside comments. Those paths do not
* resolve from the file that documents them, so scanning raw source reports
* them as broken imports.
* them as broken imports. A comment marker inside a string is text
* (`with ($asset: '//cdn/x')`), and so is a `//` in an unquoted
* `url(https://…)`; anywhere else, even right after `:` or `(`, `//` opens
* a comment.
*/
export function stripScssComments(source) {
const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, ' ');
return withoutBlocks
.split('\n')
.map((line) => {
const commentStart = line.search(/(^|[^:])\/\//);
if (commentStart === -1) return line;
return line.slice(
0,
line[commentStart] === '/' ? commentStart : commentStart + 1
);
})
.join('\n');
// Blank rather than cut, so every offset still points into `source`.
const out = source.split('');
const blank = (from, to) => {
for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' ';
return to - 1;
};
let quote = '';
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
if (quote) {
if (char === '\\') i += 1;
else if (char === quote || char === '\n') quote = '';
} else if (char === '"' || char === "'") {
quote = char;
} else if (source.startsWith('/*', i)) {
const end = source.indexOf('*/', i + 2);
i = blank(i, end === -1 ? source.length : end + 2);
} else if (source.startsWith('//', i) && !inUrl(source, i)) {
const end = source.indexOf('\n', i);
i = blank(i, end === -1 ? source.length : end);
}
}
return out.join('');
}
/**
* Where a rule's clause ends: at a `;`, `{` or `}` outside a string and a
* Sass interpolation, so a quoted `;` in a configuration
* (`'data:image/svg+xml;utf8,…'`) and `#{600}` are values.
*/
function clauseEnd(text, start) {
let quote = '';
let interpolation = 0;
for (let i = start; i < text.length; i += 1) {
const char = text[i];
if (quote) {
if (char === '\\') i += 1;
else if (char === quote || char === '\n') quote = '';
} else if (char === '"' || char === "'") {
quote = char;
} else if (text.startsWith('#{', i)) {
interpolation += 1;
i += 1;
} else if (char === '}' && interpolation > 0) {
interpolation -= 1;
} else if (';{}'.includes(char)) {
return i;
}
}
return text.length;
}
/**
* Every `@use`/`@forward`/`@import` target, with the rule that loads it, any
* `as` clause (`as t`, `as *`, or a `@forward … as btn-*` prefix), a
* `@forward`'s `show`/`hide` member list (`filter`, or `null`), where the
* rule starts (`index`) and where its `with (…)` configuration sits in
* `source` (`[start, end)`, or `null`).
*/
export function extractStylesheetLoads(source) {
const loads = [];
const stripped = stripScssComments(source);
for (const match of stripped.matchAll(STYLESHEET_RULE)) {
const rule = match[1];
const clauseStart = match.index + match[0].length;
const clause = stripped.slice(
clauseStart,
clauseEnd(stripped, clauseStart)
);
const rest = clause.replace(QUOTED_TARGET, (quoted) =>
' '.repeat(quoted.length)
);
const as =
rule === 'import'
? null
: (/\bas\s+(\*|[\w-]+\*?)/.exec(rest)?.[1] ?? null);
const opening = /\bwith\s*\(/.exec(rest);
const configuration = opening
? [
clauseStart + opening.index + opening[0].length,
clauseStart + rest.lastIndexOf(')'),
]
: null;
// The list ends where a `with (…)` starts; its values are not names.
const listed = /\b(show|hide)\s+([\s\S]*)/.exec(
rest.slice(0, opening?.index ?? rest.length)
);
const filter =
rule === 'forward' && listed
? {
kind: listed[1],
names: listed[2]
.split(',')
.map((name) => name.trim())
.filter(Boolean),
}
: null;
for (const target of targetsOfRule(rule, clause)) {
loads.push({
...{ rule, target, as, configuration, filter },
index: match.index,
});
}
}
return loads;
}
export function extractRelativeImports(source) {
const specifiers = [];
const stripped = stripScssComments(source);
for (const [, rule, clause] of stripped.matchAll(STYLESHEET_RULE)) {
for (const target of targetsOfRule(rule, clause)) {
if (target.startsWith('.')) specifiers.push(target);
}
}
return specifiers;
return extractStylesheetLoads(source)
.map(({ target }) => target)
.filter((target) => target.startsWith('.'));
}
/** Mirrors Sass partial resolution for a relative specifier. */
+67
View File
@@ -4,6 +4,7 @@ import { test } from 'node:test';
import {
extractRelativeImports,
extractStylesheetLoads,
resolveStylesheet,
stripScssComments,
validateScanCoverage,
@@ -159,6 +160,30 @@ test('treats a @use configuration value as a value, not a second import', () =>
]);
});
test('reads the show or hide list of a @forward', () => {
const source = [
"@forward 'a' as p-* hide $p-w, mixin-x;",
"@forward 'b' show $w with ($w: 600);",
"@forward 'd' with ($mode: hide auto);",
"@forward 'show-tokens' as show-*;",
"@use 'c' as show;",
].join('\n');
assert.deepEqual(
extractStylesheetLoads(source).map(({ target, filter }) => [
target,
filter,
]),
[
['a', { kind: 'hide', names: ['$p-w', 'mixin-x'] }],
['b', { kind: 'show', names: ['$w'] }],
['d', null],
['show-tokens', null],
['c', null],
]
);
});
test('ignores a url() import the browser resolves at runtime', () => {
assert.deepEqual(extractRelativeImports('@import url("./plain.css");'), []);
});
@@ -172,6 +197,48 @@ test('keeps protocol slashes intact when stripping line comments', () => {
assert.doesNotMatch(stripped, /trailing note/);
});
test('keeps comment markers inside strings', () => {
const source = [
"@use 'tokens' with ($asset: '//cdn/x', $w: 600); // note",
'/* a */ $b: "/* kept */";',
].join('\n');
const stripped = stripScssComments(source);
assert.equal(stripped.length, source.length);
assert.match(stripped, /'\/\/cdn\/x', \$w: 600\);/);
assert.match(stripped, /"\/\* kept \*\/"/);
assert.doesNotMatch(stripped, /note|\/\* a/);
const [{ configuration }] = extractStylesheetLoads(source);
assert.equal(source.slice(...configuration), "$asset: '//cdn/x', $w: 600");
// A quoted `;` is a value, not the rule's end.
const dataUri =
"@use 'tokens' with ($asset: 'data:image/svg+xml;utf8,x', $w: 600);";
const [{ configuration: range }] = extractStylesheetLoads(dataUri);
assert.equal(
dataUri.slice(...range),
"$asset: 'data:image/svg+xml;utf8,x', $w: 600"
);
// So is a Sass interpolation's `}`.
const interpolated = "@use 'tokens' with ($w: #{600}, $h: #{$w});";
const [{ configuration: span }] = extractStylesheetLoads(interpolated);
assert.equal(interpolated.slice(...span), '$w: #{600}, $h: #{$w}');
// An escaped quote stays inside the string, an unclosed one ends at the
// line break, and an unquoted URL keeps its slashes.
for (const [text, kept, dropped] of [
["$a: 'it\\'s // kept'; // gone", /\/\/ kept/, /gone/],
["$a: 'open\n// gone", /open/, /gone/],
['$a: url(//cdn/x.css); // gone', /url\(\/\/cdn/, /gone/],
['$a: url(https://cdn/x.css); // gone', /https:\/\/cdn/, /gone/],
['$a:// gone', /\$a:/, /gone/],
['// a note on url(\n// gone', /\n/, /gone/],
['$a: (// gone\n1);', /\$a: \(/, /gone/],
]) {
const result = stripScssComments(text);
assert.match(result, kept);
assert.doesNotMatch(result, dropped);
}
});
test('resolves a specifier to its Sass partial file', () => {
const existing = new Set([
path.resolve('/repo/libs/ui/styles/_detail-view.scss'),
File diff suppressed because it is too large. Load diff
+859
View File
@@ -0,0 +1,859 @@
/**
* Just enough lexing for `check-font-weights.mjs`: comments, the string that
* encloses each position, and where a declaration's value ends.
*/
const QUOTES = new Set(["'", '"', '`']);
const VALUE_END = new Set([';', '{', '}', ']']);
/**
* Plain CSS has block comments only; SCSS and TypeScript add `//`. In SCSS
* a `//` is a comment anywhere outside a string (`font-weight:// old`, `(//
* note`) except in an unquoted `url(//cdn…)` or `url(https://…)`. In
* TypeScript a URL is always inside a string, so every `//` there is a
* comment.
*/
function syntaxOf(file) {
// SVG is markup like HTML: attributes, `<!-- -->` comments, `<style>`.
if (/\.(?:html|svg)$/.test(file)) return { html: true, quotes: ['"', "'"] };
const typescript = file.endsWith('.ts');
return {
block: true,
line: !file.endsWith('.css'),
protocol: !typescript,
quotes: typescript ? ['"', "'", '`'] : ['"', "'"],
};
}
/**
* Whether `index` sits in an unquoted `url(…)` on its line, whose `//` is a
* URL; a `url(` that a comment ends with leaves the next line alone.
*/
function inUrl(source, index) {
const line = source.slice(source.lastIndexOf('\n', index - 1) + 1, index);
return /url\(\s*[^\s)'"]*$/i.test(line);
}
const CONDITION_PRELUDE = /^@(?:supports|media|container)\b/i;
/**
* Whether `index` sits in a conditional at-rule's prelude, such as the test
* of `@supports (font-weight: 650) {…}`, which is a condition rather than a
* declaration. A mixin call's arguments (`@include m($w: 650)`) do count.
*/
export function inConditionPrelude({ text, quoteAt }, index) {
const start = preludeStart(text, quoteAt, index);
return CONDITION_PRELUDE.test(text.slice(start, index).trimStart());
}
/**
* Where the statement or prelude that `index` sits in starts: just after the
* `;`, `{` or `}` before it, skipping strings and Sass interpolations
* (`@supports (min-width: #{10}px) and …`).
*/
function preludeStart(text, quoteAt, index) {
let k = index - 1;
while (k >= 0) {
if (quoteAt[k]) {
k -= 1;
} else if (text[k] === '}') {
const open = openingBrace(text, quoteAt, k);
if (open <= 0 || text[open - 1] !== '#') break;
k = open - 2;
} else if (text[k] === ';' || text[k] === '{') {
break;
} else {
k -= 1;
}
}
return k + 1;
}
/** The `{` that the `}` at `close` closes, skipping strings, or -1. */
function openingBrace(text, quoteAt, close) {
let depth = 0;
for (let i = close; i >= 0; i -= 1) {
if (quoteAt[i]) continue;
if (text[i] === '}') depth += 1;
else if (text[i] === '{' && (depth -= 1) === 0) return i;
}
return -1;
}
/** The `}` that closes the `{` at `open`, skipping strings. */
export function closingBrace({ text, quoteAt }, open) {
let depth = 0;
for (let i = open; i < text.length; i += 1) {
if (quoteAt[i]) continue;
if (text[i] === '{') depth += 1;
else if (text[i] === '}' && --depth === 0) return i;
}
return text.length;
}
/**
* Whether `index` sits in an unquoted `style=font-weight:650` attribute
* value, which runs to a space or `>`.
*/
export function inUnquotedStyle(lexed, index) {
const attribute = /(?:^|[\s<])style\s*=\s*[^\s"'=<>`]*$/i;
return (
attribute.test(lexed.text.slice(0, index)) &&
insideTag(lexed, index, { html: true })
);
}
/**
* Whether `index` sits where markup holds CSS: a `<style>` element, a
* `style` attribute, or an Angular style binding (`[style]`, `[style.x]`,
* `[ngStyle]`, `[attr.style]`, whose strings are CSS). Text content and
* other attributes or bindings (`[title]="'font-weight: 650'"`) are not.
*/
export function inMarkupCss({ text, quoteAt }, index) {
const before = text.slice(0, index).toLowerCase();
const open = before.lastIndexOf('<style');
if (open !== -1 && !before.includes('</style', open)) {
if (/^<style[\s>]/.test(before.slice(open, open + 7))) return true;
}
const quote = quoteAt[index];
if (!quote) return inUnquotedStyle({ text, quoteAt }, index);
let start = index;
while (start > 0 && quoteAt[start - 1] === quote) start -= 1;
const name = /([^\s<>="']+)\s*=\s*$/.exec(text.slice(0, start - 1))?.[1];
return /^(?:style|\[(?:style(?:\.[^\]]+)?|ngStyle|attr\.style)\])$/i.test(
name ?? ''
);
}
const BINDING_VALUE = /\[[^\]\s="'<>]+\]\s*=\s*(?:"([^"]*)|'([^']*))$/;
/**
* Where `index` sits in an Angular binding's value: `quote` is the string
* literal open there (`''` in code), `attribute` the quote that ends the
* value; `null` outside a binding. An escaped quote (`'it\'s'`) is text.
*/
function bindingAt(text, index) {
const before = text.slice(Math.max(0, index - 4096), index);
const binding = BINDING_VALUE.exec(before);
if (!binding) return null;
const value = binding[1] ?? binding[2];
let quote = '';
for (let i = 0; i < value.length; i += 1) {
if (quote && value[i] === '\\') i += 1;
else if (quote) quote = value[i] === quote ? '' : quote;
else if (QUOTES.has(value[i])) quote = value[i];
}
return { quote, attribute: binding[1] === undefined ? "'" : '"' };
}
/**
* Whether `index` sits in an Angular binding's value (`[ngStyle]="{…}"`),
* which is code, and not in a string literal inside it, which is CSS text.
*/
export function inBinding(text, index) {
return bindingAt(text, index)?.quote === '';
}
/**
* Blanks comments, keeping every newline so line numbers hold, and records
* the quote enclosing each position: a comment marker inside a string is
* text. In a stylesheet, `//` right after `:` or `(` is a URL, not a
* comment. HTML strings are attribute values, so they open only inside tags.
*/
export function lex(file, source) {
const syntax = syntaxOf(file);
const text = source.split('');
const quoteAt = new Array(source.length).fill('');
const blank = (from, to) => {
for (let k = from; k < to; k += 1) if (text[k] !== '\n') text[k] = ' ';
return to - 1;
};
const until = (marker, from) => {
const at = source.indexOf(marker, from);
return at === -1 ? source.length : at + marker.length;
};
let quote = '';
let inTag = false;
// Markup `<style>` content is CSS: block comments and strings there.
let styleTag = false;
let inStyle = false;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
if (quote) {
quoteAt[i] = quote;
if (char === '\\' && i + 1 < source.length) {
quoteAt[i + 1] = quote;
i += 1;
} else if (char === quote) {
quote = '';
} else if (char === '\n' && quote !== '`' && !syntax.html) {
quote = '';
}
} else if (syntax.html && source.startsWith('<!--', i)) {
i = blank(i, until('-->', i + 4));
} else if (syntax.html && inStyle && source.startsWith('/*', i)) {
i = blank(i, until('*/', i + 2));
} else if (syntax.html) {
if (char === '<') {
inTag = true;
const tag = source.slice(i, i + 8).toLowerCase();
if (/^<style[\s>]/.test(tag)) styleTag = true;
if (/^<\/style[\s>]/.test(tag)) inStyle = false;
}
if (char === '>') {
inTag = false;
if (styleTag) inStyle = true;
styleTag = false;
}
const css = inStyle && !inTag;
if ((inTag || css) && syntax.quotes.includes(char)) quote = char;
} else if (syntax.block && source.startsWith('/*', i)) {
i = blank(i, until('*/', i + 2));
} else if (
syntax.line &&
source.startsWith('//', i) &&
!(syntax.protocol && inUrl(source, i))
) {
const lineEnd = source.indexOf('\n', i);
i = blank(i, lineEnd === -1 ? source.length : lineEnd);
} else if (syntax.quotes.includes(char)) {
quote = char;
}
}
return { text: text.join(''), quoteAt };
}
/**
* A declaration's value, across line breaks, so a wrapped
* `var(--x,\n 650)` keeps its fallback. It stays inside the string that
* encloses the declaration (an inline style) and skips the CSS strings within
* it, so `font: 650 12px 'DM Sans'` keeps its family. Sass `#{…}` and template
* `${…}` interpolations are part of the value. It ends at `;`, a brace, or,
* outside parentheses, at a comma or the `)` that closes a Sass map or
* argument list. A `{` first means the match was a selector such as
* `.x-weight:hover`.
*/
export function valueAfter({ text, quoteAt }, start) {
const enclosing = quoteAt[start] ?? '';
let depth = 0;
let interpolation = 0;
let end = start;
for (; end < text.length; end += 1) {
const char = text[end];
if (quoteAt[end] !== enclosing) {
if (enclosing) break;
continue;
}
if (enclosing && char === enclosing) break;
if (QUOTES.has(char)) {
if (!enclosing) continue;
const close = text.indexOf(char, end + 1);
if (close === -1 || quoteAt[close] !== enclosing) break;
end = close;
} else if ((char === '#' || char === '$') && text[end + 1] === '{') {
interpolation += 1;
end += 1;
} else if (char === '}' && interpolation > 0) {
interpolation -= 1;
} else if (char === '(') {
depth += 1;
} else if (char === ')') {
if (depth === 0) break;
depth -= 1;
} else if (
VALUE_END.has(char) ||
(char === ',' && depth === 0 && interpolation === 0)
) {
break;
}
}
return { value: text.slice(start, end), selector: text[end] === '{' };
}
/** A binary operator (or a member access) that carries an expression on. */
const CONTINUES = /[-+*/%=(,?:&|!<>.]$/;
const CONTINUED = /^[-+*/%?:.,&|]/;
/**
* A JavaScript expression from `start` to its end, across line breaks: a
* `;`, a closing bracket it did not open, or (with `argument`) a top-level
* comma. A line break ends it only where the statement is complete, so
* `600 +\n50` and `600\n+ 50` both read as one expression.
*/
export function codeExpression(text, start, { argument = false } = {}) {
let depth = 0;
let quote = '';
let end = start;
for (; end < text.length; end += 1) {
const char = text[end];
if (quote) {
if (char === '\\') end += 1;
else if (char === quote) quote = '';
} else if (QUOTES.has(char)) {
quote = char;
} else if ('([{'.includes(char)) {
depth += 1;
} else if (')]}'.includes(char)) {
if (depth === 0) break;
depth -= 1;
} else if (
depth === 0 &&
(char === ';' || (argument && char === ','))
) {
break;
} else if (depth === 0 && char === '\n') {
const before = text.slice(start, end).trim();
const after = text.slice(end + 1).trimStart();
if (before && !CONTINUES.test(before) && !CONTINUED.test(after)) {
break;
}
}
}
return text.slice(start, end);
}
/**
* The string literal of code that encloses `index` (a TypeScript string, or
* one inside an Angular binding's value): its closing quote's position
* (`close`) and where the code around it ends (`limit`: the file's end, or
* the binding attribute's closing quote). `null` outside one.
*/
export function codeStringAt({ text, quoteAt }, file, index) {
if (file.endsWith('.ts')) {
const quote = quoteAt[index];
let close = index;
while (close + 1 < text.length && quoteAt[close + 1] === quote) {
close += 1;
}
return quote ? { close, limit: text.length } : null;
}
const binding = bindingAt(text, index);
if (!binding?.quote) return null;
const close = text.indexOf(binding.quote, index);
const limit = text.indexOf(binding.attribute, close + 1);
return close !== -1 && limit !== -1 ? { close, limit } : null;
}
/**
* The code a string's CSS text continues with: the first operand after a
* `+` right after the string that adds more than whitespace
* (`'font-weight:' + ' ' + 650 + ';'` gives `650`), or `null` when no `+`
* follows.
*/
export function concatenatedAfter(text, { close, limit }) {
const code = text.slice(0, limit);
const plus = /^\s*\+\s*/.exec(code.slice(close + 1));
if (!plus) return null;
const start = close + 1 + plus[0].length;
const expression = codeExpression(code, start, { argument: true });
const operands = splitAt(expression, ['+']);
const blank = /^\s*(['"`])\s*\1\s*$/;
return operands.find((operand) => !blank.test(operand)) ?? '';
}
/**
* A template literal's body split into text and `${…}` code, in order
* (`65${0}` is `[{ text: '65' }, { code: '0' }]`). An escaped character is
* text.
*/
export function templateParts(body) {
const parts = [];
let text = '';
for (let i = 0; i < body.length; i += 1) {
if (body[i] === '\\') {
text += body[i + 1] ?? '';
i += 1;
continue;
}
const code = body.startsWith('${', i)
? codeExpression(body, i + 2)
: null;
if (code === null) {
text += body[i];
continue;
}
if (text) parts.push({ text });
text = '';
parts.push({ code });
i += 2 + code.length;
}
if (text) parts.push({ text });
return parts;
}
/** The bracket depth at each position of code, or -1 inside a string. */
function depthsOf(text) {
const depths = new Array(text.length).fill(-1);
let depth = 0;
let quote = '';
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (quote) {
if (char === '\\') i += 1;
else if (char === quote) quote = '';
} else if (QUOTES.has(char)) {
quote = char;
} else {
if (')]}'.includes(char)) depth -= 1;
depths[i] = depth;
if ('([{'.includes(char)) depth += 1;
}
}
return depths;
}
/** Where `text` splits at a top-level operator from `operators`. */
function splitAt(text, operators) {
const depths = depthsOf(text);
const parts = [];
let from = 0;
for (let i = 0; i < text.length; i += 1) {
if (depths[i] !== 0 || i < from) continue;
const operator = operators.find((op) => text.startsWith(op, i));
// `||=` and kin assign; `=>` and `<<`/`>>` are not comparisons.
if (!operator || /^[=<>]/.test(text[i + operator.length] ?? '')) {
continue;
}
if (/[=<>!]/.test(text[i - 1] ?? '')) continue;
parts.push(text.slice(from, i));
from = i + operator.length;
}
return [...parts, text.slice(from)];
}
/** The branches of a top-level `c ? a : b`, or `null`. */
function branchesOf(text) {
const depths = depthsOf(text);
let question = -1;
let nested = 0;
for (let i = 0; i < text.length; i += 1) {
if (depths[i] !== 0) continue;
if (text[i] === '?') {
// `??` is nullish; `?.` chains, unless a digit follows (`?.5`).
const pair = text[i + 1] === '?' || text[i - 1] === '?';
const chain = text[i + 1] === '.' && !/\d/.test(text[i + 2] ?? '');
if (pair || chain) continue;
if (question === -1) question = i;
else nested += 1;
} else if (text[i] === ':' && question !== -1) {
if (nested === 0) {
return [text.slice(question + 1, i), text.slice(i + 1)];
}
nested -= 1;
}
}
return null;
}
const COMPARISONS = ['===', '!==', '==', '!=', '<=', '>=', '<', '>'];
/**
* What a JavaScript expression can evaluate to, as sub-expressions: both
* branches of `c ? a : b`, every operand of `||`, `??` and `&&`, and nothing
* for a comparison, which is a boolean. A condition never becomes the value,
* so `width >= 768 ? 700 : 600` is 700 or 600.
*/
export function resultsOf(expression) {
let text = expression.trim();
// `(…)` around the whole expression.
while (
text.startsWith('(') &&
text.endsWith(')') &&
depthsOf(text)
.slice(1, -1)
.every((depth) => depth !== 0)
) {
text = text.slice(1, -1).trim();
}
const branches = branchesOf(text);
if (branches) return branches.flatMap(resultsOf);
const operands = splitAt(text, ['||', '??', '&&']);
if (operands.length > 1) return operands.flatMap(resultsOf);
if (splitAt(text, COMPARISONS).length > 1) return [];
return [text];
}
/**
* Whitespace-separated tokens, keeping `var(--x, 650)`, a quoted family
* such as `"DM Sans"` and an escaped space in one piece.
*/
export function tokensOf(value) {
const tokens = [];
let depth = 0;
let quote = '';
let current = '';
const text = value.trim();
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
// An escaped character (`JetBrains\ Mono`) is text.
if (char === '\\') {
current += text.slice(i, i + 2);
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
} else if (QUOTES.has(char)) {
quote = char;
} else if (char === '(') {
depth += 1;
} else if (char === ')') {
depth -= 1;
}
if (!quote && depth === 0 && /\s/.test(char)) {
if (current) tokens.push(current);
current = '';
} else {
current += char;
}
}
if (current) tokens.push(current);
return tokens;
}
/**
* Whether `index` sits in a tag's attribute list: scanning the markup before
* it (the whole file for HTML, the enclosing string for TypeScript), a `<`
* followed by a letter, `/` or `!` opens a tag, a `>` outside an attribute
* value closes it, and quoted values are skipped (an escaped `\"` is still a
* quote to this scan). So
* `<text aria-label="x > y" font-weight=…>` is inside a tag, while text such
* as `<p>a < b font-weight=…</p>` or a `title="font-weight=…"` value is not.
*/
export function insideTag({ text, quoteAt }, index, { html = false } = {}) {
let start = 0;
if (!html) {
start = index;
while (start > 0 && quoteAt[start - 1] === quoteAt[index]) start -= 1;
}
const markup = text.slice(start, index);
let inTag = false;
let quote = '';
for (let i = 0; i < markup.length; i += 1) {
const char = markup[i];
if (quote) {
if (char === quote) quote = '';
} else if (char === '<' && /[a-z/!]/i.test(markup[i + 1] ?? '')) {
inTag = true;
} else if (inTag && char === '>') {
inTag = false;
} else if (inTag && (char === '"' || char === "'")) {
quote = char;
}
}
return inTag && !quote;
}
const FLOW = /^@(?:if|else|each|for|while)\b/i;
const CALLABLE = /^@(?:mixin|function)\b/i;
/** Sass reads `-` and `_` in a name alike; callable names keep `-`. */
const callableName = (name) => name?.replace(/_/g, '-') ?? null;
/**
* Whether `index` follows `@mixin` or `@function` and whitespace (blanked
* comments included), so the name there opens a signature, not a call.
*/
export function namesCallable(text, index) {
let k = index;
while (k > 0 && /\s/.test(text[k - 1])) k -= 1;
return (
k < index &&
/@(?:mixin|function)$/i.test(text.slice(Math.max(0, k - 9), k))
);
}
/**
* What opens the block at `brace`: flow control, a callable (with its name,
* `_` read as `-`) or a rule, with its `prelude` (the selector or at-rule).
*/
function kindOf(text, quoteAt, brace) {
const prelude = text
.slice(preludeStart(text, quoteAt, brace), brace)
.trim();
if (FLOW.test(prelude)) return { kind: 'flow', prelude };
const callable = CALLABLE.exec(prelude);
if (callable) {
const name = /^@\w+\s+([\w-]+)/.exec(prelude)?.[1];
return { kind: 'callable', name: callableName(name), prelude };
}
return { kind: 'rule', prelude };
}
/**
* The `{…}` blocks of a stylesheet as `{ start, end, kind, prelude }` (offsets of the
* braces), skipping braces inside strings. An interpolation `#{…}` is a block
* too, which is harmless: no declaration sits inside one.
*/
export function blocksOf({ text, quoteAt }) {
const blocks = [];
const open = [];
for (let i = 0; i < text.length; i += 1) {
if (quoteAt[i]) continue;
if (text[i] === '{') open.push(i);
else if (text[i] === '}' && open.length > 0) {
const start = open.pop();
blocks.push({ start, end: i, ...kindOf(text, quoteAt, start) });
}
}
return blocks.sort((a, b) => a.start - b.start);
}
/**
* Where `index` sits in the Sass scope tree. `scopes` lists the scopes a name
* there resolves through, innermost first and ending in `null` (the module).
* Flow-control blocks (`@if`, `@each`, …) are not scopes of their own: Sass
* assigns to the enclosing scope's variable. `scope` is the innermost scope,
* `conditional` says a flow-control block lies in between, `flow` that one
* encloses `index` at any depth, `inCallable`
* whether a `@mixin`/`@function` body encloses `index`, and `callable` the
* name of the innermost one.
*/
export function placeOf(blocks, index) {
const around = blocks
.filter((block) => block.start < index && index < block.end)
.reverse();
const scopes = around
.filter((block) => block.kind !== 'flow')
.map((block) => block.start);
const firstScope = around.findIndex((block) => block.kind !== 'flow');
return {
scopes: [...scopes, null],
scope: scopes[0] ?? null,
conditional: firstScope === -1 ? around.length > 0 : firstScope > 0,
flow: around.some((block) => block.kind === 'flow'),
inCallable: around.some((block) => block.kind === 'callable'),
callable:
around.find((block) => block.kind === 'callable')?.name ?? null,
};
}
/** `ns.name` as `{ name, namespace }`, with `_` read as `-`. */
function calleeNamed(full) {
const parts = full.split('.');
const name = parts.pop();
return { name: callableName(name), namespace: parts.pop() ?? null };
}
/**
* The mixin or function an argument at `index` is passed to, as
* `{ name, namespace, paren, signature }`: the name before the `(` that
* encloses it (`ns.name(` gives both; `_` reads as `-`), or `with` for a
* `@use … with (…)`. `paren` is where that `(` sits; `signature` says it
* opens a `@mixin`/`@function` parameter list, so the argument is a default.
*/
export function calleeOf(text, index) {
let depth = 0;
for (let k = index - 1; k >= 0; k -= 1) {
if (text[k] === ')') depth += 1;
else if (text[k] === '(') {
if (depth === 0) {
const named = /([\w.-]+)\s*$/.exec(text.slice(0, k));
if (!named) return null;
return {
...calleeNamed(named[1]),
paren: k,
signature: namesCallable(text, named.index),
};
}
depth -= 1;
}
}
return null;
}
const INVOCATION =
/@include\s+([\w.-]+)|(?<![\w$.@#-])([\w-]+(?:\.[\w-]+)?)(?=\s*\()/gi;
/**
* Every mixin include and function call in a stylesheet, as
* `{ index, paren, callee }`: `paren` is where its argument list opens, or
* `null` for an `@include name;` without one. Strings and the names in
* `@mixin`/`@function` signatures are skipped.
*/
export function invocationsOf({ text, quoteAt }) {
const calls = [];
for (const match of text.matchAll(INVOCATION)) {
if (quoteAt[match.index] || namesCallable(text, match.index)) continue;
const end = match.index + match[0].length;
const open = /^\s*\(/.exec(text.slice(end));
calls.push({
index: match.index,
paren: open ? end + open[0].length - 1 : null,
callee: calleeNamed(match[1] ?? match[2]),
});
}
return calls;
}
/** A CSS escape: up to six hex digits (one whitespace after them ends it). */
const ESCAPE = /\\(?:([\da-f]{1,6})(?:\r\n|[ \t\r\n\f])?|([^\n\r\f]))/iy;
/** The name characters `text` ends with (`font-w` in `.x { font-w`). */
function trailingName(text) {
let start = text.length;
while (start > 0 && /[\w-]/.test(text[start - 1])) start -= 1;
return text.slice(start);
}
/**
* The name character an escape decodes to after `name` (the name characters
* before it), or `''` where it stays an escape: past ASCII, outside a name,
* or a digit or `-` that would start one.
*/
function escapedName(escape, name) {
const code = escape[1] ? Number.parseInt(escape[1], 16) : null;
const char = code === null ? escape[2] : String.fromCharCode(code);
if ((code !== null && code >= 0x80) || !/^[\w-]$/.test(char)) return '';
if (/^(?:-?[a-z_]|--)/i.test(name)) return char;
return /^-?$/.test(name) && /^[a-z_]$/i.test(char) ? char : '';
}
/**
* `source` with the CSS escapes that Sass and the browser read as plain name
* characters decoded (`font-w\65 ight` is `font-weight`, `b\6f ld` is
* `bold`, `--w\65 ight` is `--weight`), and `origin`, the offset in `source`
* of each position (`null` when nothing changed). An escape stays where its
* character would change the token, as Sass leaves it: a digit or `-`
* starting a name (`\36 50` is a name, not 650), anything after a number
* (`6\35 0`, `6\65 2`), and a character no name has (`\:`, `\20`, `\'`).
* Comments and TypeScript are left as written (TypeScript strings use
* JavaScript escapes).
*/
export function decodeEscapes(file, source) {
if (file.endsWith('.ts') || !source.includes('\\')) {
return { text: source, origin: null };
}
// A comment is no CSS: `// note \65` must not take the next line.
const plain = lex(file, source).text;
let text = '';
const origin = [];
for (let i = 0; i < source.length; i += 1) {
ESCAPE.lastIndex = i;
const escape = plain[i] === '\\' ? ESCAPE.exec(source) : null;
const length = escape ? escape[0].length : 1;
const char = escape ? escapedName(escape, trailingName(text)) : '';
if (char) {
text += char;
origin.push(i);
} else {
text += source.slice(i, i + length);
for (let k = i; k < i + length; k += 1) origin.push(k);
}
i += length - 1;
}
origin.push(source.length);
return { text, origin };
}
/**
* A character reference markup decodes: numeric (`&#54;`, `&#x36;`, the `;`
* optional) or named, and the named ones CSS text can use.
*/
const REFERENCE = /&(?:#(\d+);?|#x([\da-f]+);?|([a-z]+);)/iy;
const NAMED = Object.freeze({
...{ quot: '"', QUOT: '"', apos: "'", colon: ':', semi: ';', excl: '!' },
...{ lpar: '(', rpar: ')', comma: ',', period: '.', plus: '+', sol: '/' },
...{ bsol: '\\', percnt: '%', lowbar: '_', num: '#', Tab: '\t' },
...{ NewLine: '\n', nbsp: '\u00a0' },
});
/**
* The text a reference stands for inside `quote` (the quoted attribute
* value's quote, or `''`), or `''` where it stays as written: `<` and `>`
* would change the markup, so they never decode, and the value's own quote
* decodes as the other one (CSS reads both alike).
*/
function referenced(reference, quote) {
const [, decimal, hex, name] = reference;
const code = decimal ?? hex;
let char = Object.hasOwn(NAMED, name ?? '') ? NAMED[name] : '';
if (code !== undefined) {
// Past the last code point it is U+FFFD (`fromCodePoint` throws).
const point = Number.parseInt(code, decimal ? 10 : 16);
char = point > 0x10ffff ? '\uFFFD' : String.fromCodePoint(point);
}
if ('<>'.includes(char)) return '';
if (char !== quote) return char;
return quote === '"' ? "'" : '"';
}
/**
* Where markup keeps its references as written: HTML's `<style>` and
* `<script>` text, and an SVG's CDATA sections.
*/
function rawRanges(file, source) {
const raw = file.endsWith('.svg')
? /<!\[CDATA\[[\s\S]*?(?:\]\]>|$)/g
: /<(style|script)\b[^>]*>[\s\S]*?(?:<\/\1|$)/gi;
return [...source.matchAll(raw)].map((m) => [
m.index,
m.index + m[0].length,
]);
}
/**
* Markup with its character references decoded as the browser decodes them
* (`style="font-weight: &#x36;50"` is 650), outside comments and raw text,
* and `origin` as in `decodeEscapes`.
*/
export function decodeReferences(file, source) {
if (!/\.(?:html|svg)$/.test(file) || !source.includes('&')) {
return { text: source, origin: null };
}
const { text: plain, quoteAt } = lex(file, source);
const raw = rawRanges(file, source);
let text = '';
const origin = [];
for (let i = 0; i < source.length; i += 1) {
REFERENCE.lastIndex = i;
const kept = plain[i] !== '&' || raw.some(([a, b]) => a <= i && i < b);
const reference = kept ? null : REFERENCE.exec(source);
const length = reference ? reference[0].length : 1;
const char = reference ? referenced(reference, quoteAt[i]) : '';
if (char) {
text += char;
for (let k = 0; k < char.length; k += 1) origin.push(i);
} else {
text += source.slice(i, i + length);
for (let k = i; k < i + length; k += 1) origin.push(k);
}
i += length - 1;
}
origin.push(source.length);
return { text, origin };
}
/**
* A file as the browser reads it: markup references, then CSS escapes,
* decoded; `origin` maps each position back to `written` (`null` when
* nothing changed).
*/
export function decodeSource(file, written) {
const references = decodeReferences(file, written);
const escapes = decodeEscapes(file, references.text);
if (!escapes.origin) return references;
const { origin } = references;
return {
text: escapes.text,
origin: origin ? escapes.origin.map((i) => origin[i]) : escapes.origin,
};
}
/** 1-based line of every index, computed once per file. */
export function lineIndex(text) {
const starts = [0];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') starts.push(i + 1);
}
return (index) => {
let low = 0;
let high = starts.length - 1;
while (low < high) {
const middle = (low + high + 1) >> 1;
if (starts[middle] <= index) low = middle;
else high = middle - 1;
}
return low + 1;
};
}
+387
View File
@@ -0,0 +1,387 @@
import path from 'node:path';
/** How a module reads its own members: unprefixed, nothing hidden. */
const DIRECT = Object.freeze({ prefix: '', filters: Object.freeze([]) });
const BOTH = Object.freeze({
declarations: true,
arguments: true,
ranges: [],
before: Infinity,
exposures: [DIRECT],
});
const DECLARATIONS = Object.freeze({ ...BOTH, arguments: false });
const NOTHING = Object.freeze({
...BOTH,
declarations: false,
arguments: false,
exposures: [],
});
/** Sass reads `-` and `_` in a name as the same character. */
export function sassName(name) {
return name.replace(/_/g, '-');
}
/** `$w` (or a mixin or function `m`) with `prefix` after its `$`. */
function prefixed(prefix, name) {
return sassName(
name.startsWith('$') ? `$${prefix}${name.slice(1)}` : prefix + name
);
}
/**
* The name a loader reads a member `name` by through `exposure`: under its
* prefix, or `null` where a `show`/`hide` on the way leaves it out.
*/
export function exposedName({ prefix, filters }, name) {
const exposed = prefixed(prefix, name);
const kept = filters.every(
({ kind, names }) => names.has(exposed) === (kind === 'show')
);
return kept ? exposed : null;
}
const keyOf = ({ prefix, filters }) =>
[prefix, ...filters.map((filter) => filter.key)].join(' ');
/**
* A forwarding cycle with prefixes would grow without end; without them, a
* list already on the way is not added again, so the cycle repeats a key.
*/
const tooLong = ({ prefix }) => prefix.length > 200;
/**
* One more `@forward` below `exposure`: its `as p-*` prefix goes after the
* ones above, and its `show`/`hide` names, written as that `@forward`
* exposes them, read under the prefixes above.
*/
function deeper(exposure, { prefix, filter }) {
const filters = [...exposure.filters];
if (filter) {
const names = new Set(
filter.names.map((name) => prefixed(exposure.prefix, name))
);
const key = `${filter.kind}:${[...names].sort().join(',')}`;
if (!filters.some((known) => known.key === key)) {
filters.push({ kind: filter.kind, names, key });
}
}
return { prefix: exposure.prefix + prefix, filters };
}
/**
* The files a Sass load can name, in Sass's resolution order. Bare targets
* (`@use 'tokens'`) resolve next to the loading file first; package and
* built-in modules (`@angular/material`, `sass:math`) are not workspace files
* and simply match nothing.
*/
function candidates(from, specifier) {
const target = path.posix.join(path.posix.dirname(from), specifier);
const dir = path.posix.dirname(target);
const base = path.posix.basename(target);
return [
target,
`${target}.scss`,
`${dir}/_${base}.scss`,
`${target}/_index.scss`,
`${target}/index.scss`,
];
}
/** `@use '../x/_tokens'` is namespaced `tokens` unless `as` renames it. */
function namespaceOf({ target, as }) {
if (as) return as;
return path.posix
.basename(target)
.replace(/\.s?css$/, '')
.replace(/^_/, '');
}
function push(map, key, value) {
if (!map.has(key)) map.set(key, []);
map.get(key).push(value);
}
function merge(scope, file, access) {
const known = scope.get(file) ?? {
...NOTHING,
ranges: [],
before: -Infinity,
};
const exposures = new Map(
[...known.exposures, ...access.exposures].map((e) => [keyOf(e), e])
);
scope.set(file, {
declarations: known.declarations || access.declarations,
arguments: known.arguments || access.arguments,
ranges: [...known.ranges, ...access.ranges],
// A file can count only up to a position (an `@import` of it).
before: Math.max(known.before, access.before),
exposures: [...exposures.values()],
});
}
/** `with (…)` ranges whose names read as `exposure` turns them. */
const rangesOf = (ranges, exposure, outward = false) =>
ranges.map(([start, end]) => ({ start, end, exposure, outward }));
/**
* Which definitions a Sass variable can resolve to, following the module
* system. `declarations` are `$x: 1;` statements; `arguments` are `$x: 1`
* inside a mixin call or a `with (…)` configuration.
*
* `qualified(file, ns)` — `ns.$x`: the members of the module the file loads
* as `ns` (its declarations and, transitively, what it `@forward`s), plus the
* arguments written inside that `@use`'s own `with (…)` (or a `@forward …
* with (…)` in the chain), by position, so a same-named argument elsewhere,
* or another module's configuration, never counts.
*
* `unqualified(file)` — `$x`, per file:
* - the file itself: both;
* - members of modules it loads `as *`, and files it `@import`s (textual
* inclusion, transitively) with what they `@forward`: their declarations;
* - files that load it: only what they pass in, since `@use` never injects
* the loader's own variables. That is the `with (…)` configuration of the
* load and arguments, which the caller then matches to the callable they
* are passed to. A chain of `@import`s is textual, so there the loader's
* declarations count too.
*
* A namespaced `@use` adds nothing unqualified, and two files that only share
* a partial are never connected. Each file carries the `exposures` it is read
* through (see `exposedName`): a `@forward … as p-*` chain prefixes members,
* and its `show`/`hide` lists leave some out. Each `with (…)` range carries
* the exposure that turns the names written in it into the names read
* (`outward` when the reader is the configured module, read before the
* prefix). Sass lets a loader configure a hidden member, so only prefixes
* apply there. A textual importer counts only `before` its `@import`.
* `scans` carry `file` and `loads` (`{ rule, target, as, configuration,
* filter }`).
*/
export function sassScopes(scans) {
const known = new Set(scans.map((scan) => scan.file));
const edges = new Map();
const loadedBy = new Map();
for (const { file, loads = [] } of scans) {
for (const load of loads) {
const loaded = candidates(file, load.target).find((c) =>
known.has(c)
);
if (!loaded) continue;
const namespace = load.rule === 'use' ? namespaceOf(load) : null;
const ranges = load.configuration ? [load.configuration] : [];
const forward = load.rule === 'forward';
// `@forward 'x' as btn-*` exposes `$v` as `$btn-v`.
const prefix =
forward && load.as?.endsWith('*') ? load.as.slice(0, -1) : '';
const filter = forward ? load.filter : null;
push(edges, file, {
...{ rule: load.rule, loaded, namespace, ranges, prefix },
...{ filter, index: load.index },
});
push(loadedBy, loaded, {
...{ file, textual: load.rule === 'import', ranges, prefix },
forward,
index: load.index,
});
}
}
// A module's members: how each file is exposed, and where a forwarding
// `with (…)` sits.
const membersCache = new Map();
const members = (module) => {
if (membersCache.has(module)) return membersCache.get(module);
const found = new Map();
const reach = (file, exposure) => {
if (!found.has(file))
found.set(file, { ranges: [], exposures: [] });
const { exposures } = found.get(file);
if (!exposures.some((e) => keyOf(e) === keyOf(exposure))) {
exposures.push(exposure);
}
};
reach(module, DIRECT);
membersCache.set(module, found);
const stack = [{ file: module, exposure: DIRECT }];
const seen = new Set([`${module} ${keyOf(DIRECT)}`]);
while (stack.length > 0) {
const { file: current, exposure } = stack.pop();
for (const edge of edges.get(current) ?? []) {
if (edge.rule !== 'forward') continue;
const next = deeper(exposure, edge);
// Its `with (…)` names the forwarded module's own members,
// which a reader of this module sees through `next`.
found.get(current).ranges.push(...rangesOf(edge.ranges, next));
reach(edge.loaded, next);
const key = `${edge.loaded} ${keyOf(next)}`;
if (seen.has(key) || tooLong(next)) continue;
seen.add(key);
stack.push({ file: edge.loaded, exposure: next });
}
}
return found;
};
const bringIn = (scope, module) => {
for (const [member, { ranges, exposures }] of members(module)) {
merge(scope, member, {
...DECLARATIONS,
arguments: ranges.length > 0,
...{ ranges, exposures },
});
}
};
const qualified = (file, namespace) => {
const scope = new Map();
for (const edge of edges.get(file) ?? []) {
if (edge.rule !== 'use' || edge.namespace !== namespace) continue;
merge(scope, file, {
...NOTHING,
ranges: rangesOf(edge.ranges, DIRECT),
});
for (const [member, { ranges, exposures }] of members(
edge.loaded
)) {
merge(scope, member, { ...DECLARATIONS, ranges, exposures });
}
}
return scope;
};
const cache = new Map();
const unqualified = (file) => {
if (cache.has(file)) return cache.get(file);
const scope = new Map([[file, BOTH]]);
const down = [file];
const imported = new Set(down);
while (down.length > 0) {
const current = down.pop();
for (const edge of edges.get(current) ?? []) {
if (edge.rule === 'use' && edge.namespace === '*') {
// Its `with (…)` configures what the loading file reads.
merge(scope, current, {
...BOTH,
ranges: rangesOf(edge.ranges, DIRECT),
});
bringIn(scope, edge.loaded);
} else if (edge.rule === 'import') {
// An imported file's `@forward`s reach the importer too.
bringIn(scope, edge.loaded);
if (!imported.has(edge.loaded)) {
imported.add(edge.loaded);
down.push(edge.loaded);
}
}
}
}
// `exposure` turns this file's names into those the loader writes:
// a loader of a `@forward … as p-*` configures `$w` as `$p-w`.
const up = [{ file, textual: true, exposure: DIRECT }];
const visitedUp = new Set([`${file} true ${keyOf(DIRECT)}`]);
while (up.length > 0) {
const current = up.pop();
for (const loader of loadedBy.get(current.file) ?? []) {
const textual = current.textual && loader.textual;
// A textual importer's code before the `@import` has run when
// the imported file's rules render; what follows has not.
merge(scope, loader.file, {
...{ declarations: textual, arguments: true },
ranges: rangesOf(loader.ranges, current.exposure, true),
before: textual ? loader.index : Infinity,
exposures: [DIRECT],
});
const exposure = {
prefix: loader.prefix + current.exposure.prefix,
filters: [],
};
const key = `${loader.file} ${textual} ${keyOf(exposure)}`;
if (visitedUp.has(key) || tooLong(exposure)) continue;
visitedUp.add(key);
up.push({ file: loader.file, textual, exposure });
}
}
cache.set(file, scope);
return scope;
};
/**
* The loads of `file`: who loads it, the `with (…)` ranges (`[start,
* end)`) on each, and for a `@forward`, the prefix it adds.
*/
const loadsOf = (file) => loadedBy.get(file) ?? [];
/** Files `@import`ed by `file`, with where each `@import` sits. */
const imports = (file) =>
(edges.get(file) ?? [])
.filter((edge) => edge.rule === 'import')
.map(({ loaded, index }) => ({ loaded, index }));
return { qualified, unqualified, imports, loadsOf };
}
/**
* The declarations of one name in the reference's own file that can be in
* effect at the reference, as Sass runs them. Scopes are tried innermost
* first; in the first that declares the name before the reference, the last
* unconditional assignment counts, plus any conditional (flow-control) ones
* after it, and an inner declaration shadows outer ones. A scope with only
* conditional assignments falls through to the next. Inside a `@mixin` or
* `@function` body, module variables are read at call time: the top level is
* resolved at each call site in the file (`callSites`) and at the end of the
* module, for callers elsewhere. A `!default` assignment counts only where
* no unconditional value precedes it, and never settles the value. Imported
* declarations keep the text order of their inclusion. `settled` means an
* unconditional declaration decided the value.
*/
export function effectiveDeclarations(reference, candidates, callSites = []) {
const picked = new Set();
// Text order; an imported declaration carries its path (`order`).
const orderOf = (d) => d.order ?? [d.index];
const compare = (a, b) => {
const [x, y] = [orderOf(a), orderOf(b)];
for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
if (x[i] !== y[i]) return x[i] - y[i];
}
return x.length - y.length;
};
const firm = (d) => !d.conditional && !d.fallback;
// `!default` assigns only while the name is unset, and `null` counts as
// unset: the latest unconditional assignment before it, in its scope or
// an outer one, wins unless it is `null`.
const rank = new Map(reference.scopes.map((scope, i) => [scope, i]));
const live = candidates.filter((d) => {
if (!d.fallback) return true;
const earlier = candidates
.filter((f) => firm(f) && compare(f, d) < 0)
.filter(
(f) => (rank.get(f.scope) ?? -1) >= (rank.get(d.scope) ?? -1)
)
.sort(compare)
.pop();
return !earlier || /^null\b/i.test(earlier.value.trim());
});
const inEffect = (here) => {
const last = here.map(firm).lastIndexOf(true);
for (const d of last === -1 ? here : here.slice(last)) picked.add(d);
return last !== -1;
};
const at = (scope, position) =>
live
.filter((d) => d.scope === scope && d.index < position)
.sort(compare);
for (const scope of reference.scopes) {
if (scope === null && reference.inCallable) {
const positions = [...callSites, Infinity];
const settled = positions
.map((position) => inEffect(at(null, position)))
.every(Boolean);
return { picked: [...picked], settled };
}
const here = at(scope, reference.index);
if (here.length > 0 && inEffect(here)) {
return { picked: [...picked], settled: true };
}
}
return { picked: [...picked], settled: false };
}