feat(tools): land mixins from another Sass module where they are included (#1795)

A mixin included from another Sass module (`@use 'x'; @include x.m`) now
lands where it is included, as a same-file mixin already did: its
top-level weights meet the including rule's family, and its family
becomes that rule's, in the order Sass writes them out. Include sites
resolve through the existing `@use`/`@forward` scope resolution, the
definition Sass resolves is the one that runs, and an include inside a
mixin body resolves where that mixin runs. The header's "Not traced" list
keeps what stays out (positional arguments, content blocks placed by
another module's mixin, a name two `@import`ed files define).

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 10:46:32 +02:00
1 parent e8b181fcea
commit 040acbd976
5 files changed
+1270 -142

No files matched your search

+4 -2
View File
@@ -908,8 +908,10 @@ heavier faces would exceed the initial-bytes ratchet).
- JetBrains Mono text stays at 500 or lighter, also where it is a fallback
behind `ui-monospace` (only macOS resolves that). The check enforces this in
any rule that sets the family, directly or through a variable, or inherits
it from an enclosing rule. It cannot see what a mono modifier class inherits
from its base rule; set `font-weight: 500` there.
it from an enclosing rule. A mixin's family and weights count where it is
included, from its own stylesheet module or another one. It cannot see what
a mono modifier class inherits from its base rule; set `font-weight: 500`
there.
- Import whole `@fontsource/<family>/<weight>.css` files. The single-script
files such as `cyrillic-600.css` have no `unicode-range`, so a Cyrillic-only
face wins the weight match for Latin text in `Roboto, …` stacks and sends it
+429 -100
View File
@@ -73,7 +73,9 @@ import {
* 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
* in sight; against the JetBrains Mono cap, only a call that meets that
* family passes a weight (one in a mixin another module includes always
* may). 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
@@ -94,35 +96,45 @@ import {
* 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.
* declarations land where it is included, in the order Sass writes them
* out: in its own file, as the definition in scope when the include runs
* (a rule's declared before it, a mixin body's where that mixin is
* included), or in another module that includes its last definition by a
* name Sass resolves to it (`ns.m`,
* through `@forward` prefixes and `show`/`hide`, or a bare `m` that
* `@use … as *` or `@import` brings in; see `scanWorkspace`). There its weights meet the including rule's family,
* reported once at the mixin's own line, and its family becomes that
* rule's. A content block lands too where a mixin of the same file 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.
* rule, a mixin's nested rules and at-rules (and a `@content` placed in one,
* or in another module's mixin), a mixin name that two `@import`ed files
* define (the later wins), a custom property a mixin's family reads
* (resolved where the mixin is written, not on the rule that includes it),
* 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]);
@@ -886,17 +898,41 @@ function callSitesOf(text, blocks) {
return calls;
}
/**
* An `@include` of a mixin: `ns.name` names another module's, as does a
* bare name that no mixin of the including file has.
*/
const INCLUDE = /@include\s+(?:([\w-]+)\.)?([\w-]+)(?![\w.-])/g;
/**
* 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.
* Its module mixins' top-level families and weights (`mixins`) land where
* other modules include them, and theirs here: `included` gives, by the
* position of each such `@include` (`includes`), the mixins it reaches, and
* `elsewhere` names this file's mixins that other modules include (see
* `scanWorkspace`). A mixin read both ways carries the second (`after`), so
* a module that includes it reads it both ways too.
*/
export function scanWeights(file, written) {
const running = scanPass(file, written, true);
if (!running.transient) return running;
const after = scanPass(file, written, false);
export function scanWeights(file, written, modules = {}) {
const { included = new Map() } = modules;
const running = scanPass(file, written, true, modules);
const mixins = [...included.values()].flat();
if (!running.transient && !mixins.some((mixin) => mixin.after)) {
return running;
}
const after = scanPass(file, written, false, {
...modules,
included: new Map(
[...included].map(([site, reached]) => [
site,
reached.map((mixin) => mixin.after ?? mixin),
])
),
});
const keyOf = (item) => JSON.stringify(item);
const union = (a, b) => [
...new Map([...a, ...b].map((item) => [keyOf(item), item])).values(),
@@ -905,10 +941,35 @@ export function scanWeights(file, written) {
...running,
findings: union(running.findings, after.findings),
deferred: union(running.deferred, after.deferred),
// A call meets the families of either reading.
includeCalls: new Map(
[...running.includeCalls].map(([index, call]) => {
const other = after.includeCalls.get(index);
return [
index,
{
...call,
mono: call.mono || Boolean(other?.mono),
families: union(call.families, other?.families ?? []),
},
];
})
),
mixins: new Map(
[...running.mixins].map(([name, mixin]) => [
name,
{ ...mixin, after: after.mixins.get(name) },
])
),
};
}
function scanPass(file, written, transient) {
function scanPass(
file,
written,
transient,
{ included = new Map(), elsewhere = new Set() }
) {
// 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);
@@ -1067,12 +1128,52 @@ function scanPass(file, written, transient) {
selectors: selectorsOf(place.scopes),
});
const monoAt = stylesheet
? familiesOf(lexed, blocks, {
...{ inString, placeOf, refsIn, rulesOf, transient },
})
? familiesOf(
lexed,
blocks,
{ inString, placeOf, refsIn, rulesOf, transient },
{
included: new Map(
[...included].map(([site, mixins]) => [
site,
mixins.flatMap((mixin) => mixin.families),
])
),
elsewhere,
}
)
: Object.assign(() => ({ mono: false, refs: [] }), {
landings: () => [],
...{ landings: () => [], landingsAt: () => [] },
...{ memberOf: () => null, exported: new Map() },
...{ definitionsAt: () => ({ defs: [], open: true }) },
callAt: () => null,
});
// Where a family declaration sits, for its variables to resolve later
// there; one from another module's mixin carries its own.
const siteOf = (entry) =>
entry.at.file
? entry.at
: {
...{ ...entry.at, file },
guards: guardsAt(entry.at.index),
rule: blockAt.get(entry.at.scope)?.prelude ?? null,
selectors: selectorsOf(entry.at.scopes ?? []),
};
// A weight's terms and variables as a JetBrains Mono rule caps them,
// with where to report them.
const capped = (name, index, mode, value) => {
const analysis = analyse(mode, value, { cap: MONO_WEIGHT_CAP });
const place = placeOf(blocks, index);
return {
...{ file, line: lineOf(index), name },
terms: analysis.terms.filter(capOnly),
references: analysis.references.map((reference) => ({
...{ ...reference, file, index },
...{ scopes: place.scopes, callable: place.callable },
inCallable: place.inCallable,
})),
};
};
// Weights in rules whose family is named through variables: capped once
// that family resolves to JetBrains Mono.
const deferred = [];
@@ -1080,15 +1181,31 @@ function scanPass(file, written, transient) {
// 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();
// The weight declarations landing in this file's module mixins (one of
// another module's too), for the modules that include them, and how
// this file's own read where a JetBrains Mono rule caps them.
const setInMixins = [];
const weightAt = 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)) {
// where it is included), at the place it lands; `id` is its position,
// or for another module's, its file and position there. A keyframe's
// weight, where a rule runs it, outranks the rule's own (`animated`, on
// the way to a mixin another module includes, or here).
const setAt = (id, important, landings, weight = null, ran = false) => {
for (const landing of landings) {
const { rules, key, scope, animated: here } = landing;
const animated = ran || Boolean(here);
for (const rule of rules) {
if (!setters.has(rule)) setters.set(rule, []);
const level = levelOf({ important, animated });
setters.get(rule).push({ key, index, level });
setters.get(rule).push({ key, index: id, level });
}
const mixin = monoAt.memberOf(scope);
if (mixin !== null && landing.external !== false) {
setInMixins.push({
...{ mixin, key, id },
...{ important, animated, weight },
});
}
}
};
@@ -1109,15 +1226,30 @@ function scanPass(file, written, transient) {
if (match[1].toLowerCase() === 'weight' && namespace === undefined) {
continue;
}
setAt(match.index, IMPORTANT.test(value));
setAt(match.index, IMPORTANT.test(value), monoAt.landings(match.index));
}
// 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]));
if (startsDeclaration(text, match.index)) {
const landings = monoAt.landings(match.index);
setAt(match.index, Boolean(match[2]), landings);
}
}
// Another module's mixin sets its weights where this file includes it.
const landed = [...included].flatMap(([site, mixins]) =>
mixins.flatMap((mixin) =>
mixin.setters.map((setter) => ({
...{ site, setter },
landings: monoAt.landingsAt(site, setter.key),
}))
)
);
for (const { setter, landings } of landed) {
const { id, important, weight, animated } = setter;
setAt(id, important, landings, weight, animated);
}
// A shorthand that fails to parse is dropped, so it sets nothing.
@@ -1126,8 +1258,8 @@ function scanPass(file, written, transient) {
// 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 }) =>
const effectiveIn = (index, landings = monoAt.landings(index)) => {
const kept = landings.filter(({ rules, key }) =>
rules.some((id) => {
const rule = setters.get(id) ?? [];
const own = rule.find(
@@ -1147,7 +1279,7 @@ function scanPass(file, written, transient) {
);
})
);
return new Set(landings.map(({ scope }) => scope));
return new Set(kept.map(({ scope }) => scope));
};
// CSS text in a string (an inline `style="…"`, a component style) meets
@@ -1247,26 +1379,45 @@ function scanPass(file, written, transient) {
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 },
...capped(name, match.index, mode, value),
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,
})),
at: siteOf(family),
});
}
if (stylesheet && weightName) {
weightAt.set(match.index, () =>
capped(name, match.index, mode, value)
);
}
}
}
// Another module's weight landing here meets the family of the rule
// that includes it, and is reported where it is written.
for (const { site, setter, landings } of landed) {
const { id, weight } = setter;
if (!weight) continue;
const effective = effectiveIn(id, landings);
const family =
effective.size > 0
? monoAt(site, (scope) => effective.has(scope))
: { mono: false, refs: [] };
// Reported once, however many rules it lands in.
const terms = weight.terms.map((term) => ({ ...term, landed: true }));
if (family.mono) {
const { file: from, line, name } = weight;
findings.push(
...terms.map((term) => ({ file: from, line, name, ...term }))
);
references.push(...weight.references);
} else if (family.refs.length > 0) {
deferred.push({
...{ ...weight, terms },
family: family.text,
shorthand: family.shorthand,
at: siteOf(family),
});
}
}
// A weight set from code is checked here; any other custom property it
@@ -1517,12 +1668,70 @@ function scanPass(file, written, transient) {
references: analyses.flatMap((analysis) => analysis.references),
});
}
// This file's module mixins as a module that includes one sees them:
// the families and weights they set at their top level, each at its
// place in the mixin (`key`), a family with where to resolve it.
const mixins = new Map();
for (const block of blocks.filter((b) => b.kind === 'callable')) {
const name = monoAt.memberOf(block.start);
if (name !== null && !mixins.has(name)) {
mixins.set(name, { families: [], setters: [] });
}
}
const portable = new Map();
const portableOf = (entry) => {
if (!portable.has(entry)) {
portable.set(entry, { ...entry, at: siteOf(entry) });
}
return portable.get(entry);
};
for (const [name, families] of monoAt.exported) {
mixins.get(name)?.families.push(
...families.map(({ key, entry }) => ({
key,
entry: portableOf(entry),
}))
);
}
for (const { mixin, key, id, important, animated, weight } of setInMixins) {
const own = typeof id === 'number';
mixins.get(mixin)?.setters.push({
...{ key, important, animated, id: own ? `${file}:${id}` : id },
weight: own ? (weightAt.get(id)?.() ?? null) : weight,
});
}
// Where it includes another module's mixin (see `INCLUDE`): a bare
// name runs one of this file's own where one is in scope there.
const includes = [];
for (const match of stylesheet ? text.matchAll(INCLUDE) : []) {
if (inString(match.index)) continue;
const namespace = match[1] ?? null;
const name = match[2].replace(/_/g, '-');
const { defs, open } = monoAt.definitionsAt(name, match.index);
if (namespace === null && defs.length > 0 && !open) continue;
includes.push({ index: match.index, callee: { name, namespace } });
}
const loads = stylesheet ? extractStylesheetLoads(source) : [];
const calls = callSitesOf(text, blocks);
const invocations = stylesheet ? invocationsOf(lexed) : [];
// Each `@include` here, by position, with the families it meets (see
// `callAt` in `familiesOf`): a parameter capped for JetBrains Mono takes
// only the arguments of a call that meets one.
const includeCalls = new Map();
for (const { index, paren } of invocations) {
if (!/^@include\b/.test(text.slice(index, index + 8))) continue;
const { mono, families } = monoAt.callAt(index);
includeCalls.set(index, {
...{ paren, mono },
families: families.map((entry) => ({
...{ text: entry.text, shorthand: entry.shorthand },
at: siteOf(entry),
})),
});
}
return {
...{ file, loads, declarations, findings, references, definitions },
...{ calls, invocations, deferred },
...{ calls, invocations, deferred, mixins, includes, includeCalls },
transient: Boolean(monoAt.transient),
};
}
@@ -1539,7 +1748,8 @@ function scanPass(file, written, transient) {
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 { qualified, unqualified, imports, loadsOf, reaches } =
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`).
@@ -1563,35 +1773,45 @@ export function findIndirectWeights(scans) {
: 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
)
);
};
// Whether an argument is passed to `callable`, defined in `file` (see
// `reaches` in `sassScopes`).
const passedTo = reaches;
// 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 }))
);
// A parameter that a JetBrains Mono rule caps (`capped`) takes only the
// arguments, or default, of an `@include` that meets one (see
// `includeCalls` in `scanWeights`); any other call is read as before.
const includeCalls = new Map(
scans.map(({ file, includeCalls: calls = new Map() }) => [file, calls])
);
const monoCalls = new Map();
const meetsMono = (call) => {
if (!call) return true;
if (!monoCalls.has(call)) {
monoCalls.set(
call,
call.mono ||
call.families.some((f) =>
familyIsMono(f.text, f.at, new Set(), f.shorthand)
)
);
}
return monoCalls.get(call);
};
const includeAt = (file, paren) =>
[...(includeCalls.get(file)?.values() ?? [])].find(
(call) => call.paren === paren
);
const defaultUsed = new Map();
const usesDefault = (definition) => {
if (defaultUsed.has(definition)) return defaultUsed.get(definition);
const usesDefault = (definition, capped = false) => {
const id = `${capped}`;
if (!defaultUsed.has(definition)) defaultUsed.set(definition, {});
const cached = defaultUsed.get(definition);
if (id in cached) return cached[id];
const callable = definition.callee.name;
const calls = invocations.filter(
(call) =>
@@ -1606,8 +1826,13 @@ export function findIndirectWeights(scans) {
d.callee?.paren === call.paren &&
d.key === definition.key
);
const used = calls.length === 0 || calls.some((call) => !names(call));
defaultUsed.set(definition, used);
const counted = capped
? calls.filter((call) =>
meetsMono(includeCalls.get(call.file)?.get(call.index))
)
: calls;
const used = calls.length === 0 || counted.some((call) => !names(call));
cached[id] = used;
return used;
};
// A partial runs only where it is loaded, so its `!default` for a name
@@ -1806,10 +2031,19 @@ export function findIndirectWeights(scans) {
if (!visible) return false;
}
if (scope) {
const capped = Boolean(reference.cap);
const passed =
definition.key === name &&
passedTo(definition, file, reference.callable) &&
(!definition.callee.signature || usesDefault(definition));
(definition.callee.signature
? usesDefault(definition, capped)
: !capped ||
meetsMono(
includeAt(
definition.file,
definition.callee.paren
)
));
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.
@@ -2131,6 +2365,108 @@ export function findIndirectWeights(scans) {
return findings;
}
/**
* Every file's scan (`sources` holds `{ file, source }`), with the mixins
* it includes from other modules landed where it includes them: an
* `@include ns.name`, or a bare name that `@use … as *` or `@import` brings
* in, resolved as Sass resolves the call (see `sassScopes`). A file that
* includes one, or whose mixin another includes, is scanned again with
* them, after the modules it includes (Sass rejects a loop of `@use`s; a
* file met again on one keeps its first scan).
*/
export function scanWorkspace(sources) {
const sourceOf = new Map(sources.map(({ file, source }) => [file, source]));
const first = new Map(
sources.map(({ file, source }) => [file, scanWeights(file, source)])
);
const { reaches } = sassScopes([...first.values()]);
const providers = [...first.values()].filter(
(scan) => scan.mixins?.size > 0
);
// Each file's includes of other modules' mixins, by `@include`, with
// the mixins each reaches; and each file's mixins others include.
const reached = new Map();
const elsewhere = new Map();
for (const scan of first.values()) {
for (const { index, callee } of scan.includes ?? []) {
const call = { callee, file: scan.file };
// A file's own definitions land through `familiesOf`.
const found = providers
.filter(({ file }) => file !== scan.file)
.flatMap(({ file, mixins }) =>
[...mixins.keys()]
.filter((name) => reaches(call, file, name))
.map((name) => ({ file, name }))
);
// Only `@import`s can bring in two mixins of one name (anything
// else is a Sass error), and the later one wins; the scan does
// not order them, so it leaves such an include out.
if (found.length !== 1) continue;
if (!reached.has(scan.file)) reached.set(scan.file, new Map());
reached.get(scan.file).set(index, found);
for (const { file, name } of found) {
if (!elsewhere.has(file)) elsewhere.set(file, new Set());
elsewhere.get(file).add(name);
}
}
}
const done = new Map();
const scanning = new Set();
const scanOf = (file) => {
if (done.has(file)) return done.get(file);
const sites = reached.get(file) ?? new Map();
const involved = sites.size > 0 || elsewhere.has(file);
if (!involved || scanning.has(file)) return first.get(file);
scanning.add(file);
const included = new Map(
[...sites].map(([index, found]) => [
index,
found.map(({ file: from, name }) =>
scanOf(from).mixins.get(name)
),
])
);
const scan = scanWeights(file, sourceOf.get(file), {
included,
elsewhere: elsewhere.get(file),
});
scanning.delete(file);
done.set(file, scan);
return scan;
};
return sources.map(({ file }) => scanOf(file));
}
/**
* Every finding in the workspace (see `scanWorkspace`), and how many weight
* declarations it checked. A mixin's weight landing in several JetBrains
* Mono rules, in other modules or its own, is reported once.
*/
export function findWorkspaceWeights(sources) {
const scans = scanWorkspace(sources);
const all = [
...scans.flatMap((scan) => scan.findings),
...findIndirectWeights(scans),
];
const keyOf = ({ file, line, name, value, cap, computed }) =>
JSON.stringify([file, line, name, value, cap, computed]);
const seen = new Set(all.filter((f) => !f.landed).map(keyOf));
const findings = [];
for (const { landed, ...finding } of all) {
if (landed) {
const key = keyOf(finding);
if (seen.has(key)) continue;
seen.add(key);
}
findings.push(finding);
}
const declarations = scans.reduce(
(sum, scan) => sum + scan.declarations,
0
);
return { declarations, findings };
}
/** Every off-scale weight one file can reach on its own. */
export function findOffScaleWeights(file, source) {
const scan = scanWeights(file, source);
@@ -2183,23 +2519,16 @@ if (isMain) {
.filter(Boolean)
.filter(isScannedFile);
const scans = [];
const sources = [];
for (const file of files) {
const source = await readFile(path.resolve(rootDir, file), 'utf8');
scans.push(scanWeights(file, source));
sources.push({ file, source });
}
const findings = [
...scans.flatMap((scan) => scan.findings),
...findIndirectWeights(scans),
];
const { declarations, findings } = findWorkspaceWeights(sources);
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:');
+579
View File
@@ -7,6 +7,7 @@ import {
describeFinding,
findIndirectWeights,
findOffScaleWeights,
findWorkspaceWeights,
isScannedFile,
nearestScaleWeight,
scanWeights,
@@ -19,6 +20,14 @@ const offScale = (file, source) =>
({ line, name, value }) => `${line} ${name}: ${value}`
);
/** The findings across `files` (`{ path: source }`), scanned together. */
const workspace = (files) =>
findWorkspaceWeights(
Object.entries(files).map(([file, source]) => ({ file, source }))
).findings.map(
({ file, line, name, value }) => `${file}:${line} ${name}: ${value}`
);
test('flags an off-scale font-weight with its line number', () => {
const source = ['.title {', ' font-weight: 650;', '}'].join('\n');
@@ -3962,6 +3971,576 @@ test('reads mixin weights where they land, through nested includes', () => {
}
});
test("lands another module's mixin weights where they are included", () => {
const mono = "'JetBrains Mono'";
const type = [
'@mixin heavy {',
' font-weight: 700;',
'}',
'@mixin light { font-weight: 400; }',
'@mixin firm { font-weight: 700 !important; }',
'@mixin settled { font-weight: 700; font-weight: 500; }',
].join('\n');
const report = (rules) =>
workspace({
'libs/w1/_type.scss': type,
'libs/w1/c.scss': `@use 'type';\n${rules}`,
});
const heavy = ['libs/w1/_type.scss:2 font-weight: 700'];
for (const [rules, expected] of [
[`.x { font-family: ${mono}; @include type.heavy; }`, heavy],
[`.x { font-family: Roboto; @include type.heavy; }`, []],
// At the `@include`, in Sass's output order with the rule's own.
[
`.x { font-family: ${mono}; @include type.heavy; font-weight: 500; }`,
[],
],
[
`.x { font-family: ${mono}; font-weight: 500; @include type.heavy; }`,
heavy,
],
[`.x { @include type.heavy; font-family: ${mono}; }`, heavy],
[
`.x { font-family: ${mono}; @include type.heavy; @include type.light; }`,
[],
],
[
`.x { font-family: ${mono}; @include type.firm; font-weight: 500; }`,
['libs/w1/_type.scss:5 font-weight: 700'],
],
// And in its order within the mixin.
[`.x { font-family: ${mono}; @include type.settled; }`, []],
// A nested rule inherits the family; one named through a variable
// is resolved once the workspace is scanned.
[`.x { font-family: ${mono}; .y { @include type.heavy; } }`, heavy],
[
`:root { --face: ${mono}; } .x { font-family: var(--face); @include type.heavy; }`,
heavy,
],
]) {
assert.deepEqual(report(rules), expected, rules);
}
});
test("reports another module's mixin weight once, at its own line", () => {
const mono = "'JetBrains Mono'";
const users = {
'libs/w2/a.scss': `@use 'type'; .a { font-family: ${mono}; @include type.heavy; }`,
'libs/w2/b.scss': [
"@use 'type';",
`:root { --face: ${mono}; }`,
'.b { font-family: var(--face); @include type.heavy; }',
].join('\n'),
};
const heavy = ['libs/w2/_type.scss:1 font-weight: 700'];
assert.deepEqual(
workspace({
'libs/w2/_type.scss': '@mixin heavy { font-weight: 700; }',
...users,
}),
heavy
);
// Its own module includes it in a JetBrains Mono rule too.
assert.deepEqual(
workspace({
'libs/w2/_type.scss': `@mixin heavy { font-weight: 700; } .own { font-family: ${mono}; @include heavy; }`,
...users,
}),
heavy
);
});
test("gives a rule the family another module's mixin sets there", () => {
const mono = "'JetBrains Mono'";
const report = (rules, face = `${mono}, monospace`) =>
workspace({
'libs/w3/_mono.scss': `$face: ${mono}, monospace;\n@mixin face {\n font-family: ${face};\n}`,
'libs/w3/_other.scss': '@mixin face { font-family: Roboto; }',
'libs/w3/c.scss': `@use 'mono';\n@use 'other';\n${rules}`,
});
const heavy = ['libs/w3/c.scss:3 font-weight: 700'];
for (const [rules, expected, face] of [
[`.x { @include mono.face; font-weight: 700; }`, heavy],
[
`.x { @include mono.face; font-family: Roboto; font-weight: 700; }`,
[],
],
[
`.x { font-family: Roboto; @include mono.face; font-weight: 700; }`,
heavy,
],
[`.x { @include mono.face; .y { font-weight: 700; } }`, heavy],
[
`.x { @include mono.face; &:hover { font-weight: 600; } }`,
['libs/w3/c.scss:3 font-weight: 600'],
],
// Named through its own module's variable, resolved there.
[`.x { @include mono.face; font-weight: 700; }`, heavy, '$face'],
// A namesake in another module is not the mixin included.
[`.x { @include other.face; font-weight: 700; }`, []],
]) {
assert.deepEqual(report(rules, face), expected, rules);
}
});
test('reads a mixin another module includes where it lands', () => {
const mono = "'JetBrains Mono'";
const report = (rules) =>
workspace({
'libs/w4/_badge.scss': `@mixin badge {\n font-family: ${mono};\n font-weight: 700;\n}`,
'libs/w4/c.scss': `@use 'badge';\n${rules}`,
});
const heavy = ['libs/w4/_badge.scss:3 font-weight: 700'];
assert.deepEqual(
report('.x { @include badge.badge; font-family: Roboto; }'),
[]
);
assert.deepEqual(report('.x { @include badge.badge; }'), heavy);
// Included nowhere, it is read where it is written.
assert.deepEqual(report('.x { color: red; }'), heavy);
});
test("resolves another module's mixin as Sass does", () => {
const mono = "'JetBrains Mono'";
// A bare name a mixin of the file has is its own.
assert.deepEqual(
scanWeights(
'libs/w5/a.scss',
'@mixin m_a { color: red; } .x { @include m-a; @include n_b; @include t.c_d; }'
).includes.map(({ callee }) => callee),
[
{ name: 'n-b', namespace: null },
{ name: 'c-d', namespace: 't' },
]
);
const type = { 'libs/w5/_type.scss': '@mixin heavy { font-weight: 700; }' };
const heavy = ['libs/w5/_type.scss:1 font-weight: 700'];
// Through a `@forward` prefix, and as a bare name `as *` or `@import`
// brings in.
assert.deepEqual(
workspace({
...type,
'libs/w5/_theme.scss': "@forward 'type' as type-*;",
'libs/w5/c.scss': `@use 'theme'; .x { font-family: ${mono}; @include theme.type-heavy; }`,
}),
heavy
);
for (const load of ["@use 'type' as *;", "@import 'type';"]) {
assert.deepEqual(
workspace({
...type,
'libs/w5/c.scss': `${load} .x { font-family: ${mono}; @include heavy; }`,
}),
heavy,
load
);
}
// A mixin that `hide` leaves out is not the one included.
assert.deepEqual(
workspace({
...type,
'libs/w5/_quiet.scss': '@mixin heavy { font-weight: 400; }',
'libs/w5/_theme.scss':
"@forward 'type' hide heavy;\n@forward 'quiet';",
'libs/w5/c.scss': `@use 'theme'; .x { font-family: ${mono}; @include theme.heavy; }`,
}),
[]
);
// Nor one declared in a rule, which is local to it.
assert.deepEqual(
workspace({
'libs/w5/_type.scss':
'@mixin heavy { font-weight: 400; } .p { @mixin heavy { font-weight: 700; } @include heavy; }',
'libs/w5/c.scss': `@use 'type'; .x { font-family: ${mono}; @include type.heavy; }`,
}),
[]
);
// Two `@import`ed files that define it: Sass includes the later one,
// which the scan does not order, so neither lands.
assert.deepEqual(
workspace({
'libs/w5/_a.scss': '\n\n@mixin heavy { font-weight: 700; }',
'libs/w5/_b.scss': '@mixin heavy { font-weight: 400; }',
'libs/w5/c.scss': `@import 'a'; @import 'b'; .x { font-family: ${mono}; @include heavy; }`,
}),
[]
);
});
test("follows another module's mixin through the mixins it includes", () => {
const mono = "'JetBrains Mono'";
for (const [files, expected] of [
// Its family, from a mixin it includes from a third module or its
// own.
[
{
'libs/w6/_inner.scss': `@mixin face { font-family: ${mono}; }`,
'libs/w6/_outer.scss':
"@use 'inner'; @mixin title { @include inner.face; }",
'libs/w6/c.scss':
"@use 'outer'; .x { @include outer.title; font-weight: 700; }",
},
['libs/w6/c.scss:1 font-weight: 700'],
],
[
{
'libs/w6/_outer.scss': `@mixin face { font-family: ${mono}; } @mixin title { @include face; }`,
'libs/w6/c.scss':
"@use 'outer'; .x { @include outer.title; font-weight: 700; }",
},
['libs/w6/c.scss:1 font-weight: 700'],
],
// In the order Sass writes them out, wherever each is declared.
[
{
'libs/w6/_outer.scss': `@mixin title { @include face; font-family: ${mono}; } @mixin face { font-family: Roboto; }`,
'libs/w6/c.scss':
"@use 'outer'; .x { @include outer.title; font-weight: 700; }",
},
['libs/w6/c.scss:1 font-weight: 700'],
],
// Its weight, from a third module's mixin.
[
{
'libs/w6/_inner.scss': '@mixin heavy { font-weight: 700; }',
'libs/w6/_outer.scss':
"@use 'inner'; @mixin title { @include inner.heavy; }",
'libs/w6/c.scss': `@use 'outer'; .x { font-family: ${mono}; @include outer.title; }`,
},
['libs/w6/_inner.scss:1 font-weight: 700'],
],
]) {
assert.deepEqual(workspace(files), expected, JSON.stringify(files));
}
});
test("reads another module's mixin while its animation runs and after", () => {
const mono = "'JetBrains Mono'";
const anim = {
'libs/w8/_anim.scss': [
`@keyframes face { from { font-family: ${mono}; } }`,
'@keyframes swap { from { font-family: Roboto; } }',
'@keyframes heavy { from { font-weight: 700; } }',
'@mixin mono-hold { animation: face 1s forwards; }',
'@mixin roboto-while { animation: swap 1s; }',
'@mixin heavy { animation: heavy 1s; }',
`@mixin mono { font-family: ${mono}; }`,
].join('\n'),
'libs/w8/_outer.scss':
"@use 'anim'; @mixin title { @include anim.roboto-while; }",
};
const report = (rules) =>
workspace({ ...anim, 'libs/w8/c.scss': `@use 'anim';\n${rules}` });
const own = ['libs/w8/c.scss:2 font-weight: 700'];
for (const [rules, expected] of [
// A frame it holds sets the family over the rule's own.
[
`.x { font-family: Roboto; @include anim.mono-hold; font-weight: 700; }`,
own,
],
// One it only runs leaves the rule its own family after, also
// through a third module's mixin.
[
`.x { font-family: ${mono}; @include anim.roboto-while; font-weight: 700; }`,
own,
],
[
`.x { font-family: Roboto; @include anim.roboto-while; font-weight: 700; }`,
[],
],
// Its family in a keyframe here outranks the rule's own too.
[
`@keyframes k { from { @include anim.mono; } } .x { animation: k 1s forwards; font-family: Roboto; font-weight: 700; }`,
own,
],
[
`@use 'outer'; .x { font-family: ${mono}; @include outer.title; font-weight: 700; }`,
own,
],
// A keyframe's weight outranks the rule's own while it runs, a
// later one too.
[
`.x { font-family: ${mono}; font-weight: 500; @include anim.heavy; }`,
['libs/w8/_anim.scss:3 font-weight: 700'],
],
[
`.x { font-family: ${mono}; @include anim.heavy; font-weight: 500; }`,
['libs/w8/_anim.scss:3 font-weight: 700'],
],
]) {
assert.deepEqual(report(rules), expected, rules);
}
});
test("caps a named argument another module's mixin sets a weight from", () => {
const mono = "'JetBrains Mono'";
const report = (mixin, rules) =>
workspace({
'libs/w7/_type.scss': mixin,
'libs/w7/c.scss': `@use 'type';\n${rules}`,
});
assert.deepEqual(
report(
'@mixin heavy($w: 400) { font-weight: $w; }',
`.x {\n font-family: ${mono};\n @include type.heavy($w: 700);\n}`
),
['libs/w7/c.scss:4 $w: 700']
);
// Its default, where the call leaves it out.
assert.deepEqual(
report(
'@mixin heavy($w: 700) { font-weight: $w; }',
`.x { font-family: ${mono}; @include type.heavy; }`
),
['libs/w7/_type.scss:1 $w: 700']
);
});
test('runs the definition of a mixin Sass resolves at each include', () => {
const mono = "'JetBrains Mono'";
const heavy = {
'libs/w9/_type.scss': '@mixin heavy { font-weight: 700; }',
};
const imported = ['libs/w9/_type.scss:1 font-weight: 700'];
for (const [files, expected] of [
// One declared in a rule is visible there, once declared; elsewhere
// the name runs the one brought in.
[
{
...heavy,
'libs/w9/c.scss': `@use 'type' as *;\n.p { @mixin heavy { font-weight: 400; } @include heavy; }\n.x { font-family: ${mono}; @include heavy; }`,
},
imported,
],
[
{
...heavy,
'libs/w9/c.scss': `@use 'type' as *; .p { font-family: ${mono}; @mixin heavy { font-weight: 400; } @include heavy; }`,
},
[],
],
[
{
...heavy,
'libs/w9/c.scss': `@use 'type' as *; .p { font-family: ${mono}; @include heavy; @mixin heavy { font-weight: 400; } }`,
},
imported,
],
// A rule runs the definition declared before it.
[
{
'libs/w9/c.scss': `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; } @mixin m { font-weight: 400; }`,
},
['libs/w9/c.scss:1 font-weight: 700'],
],
[
{
'libs/w9/c.scss': `@mixin m { font-weight: 700; } @mixin m { font-weight: 400; } .x { font-family: ${mono}; @include m; }`,
},
[],
],
// A mixin body runs the definition in scope where it is included.
[
{
'libs/w9/c.scss': `@mixin m { font-weight: 700 !important; }\n@mixin m { font-weight: 400; }\n@mixin outer { @include m; }\n.x { font-family: ${mono}; @include outer; }`,
},
[],
],
[
{
'libs/w9/c.scss': `@mixin m { font-weight: 700; }\n@mixin outer { @include m; }\n.x { font-family: ${mono}; @include outer; }\n@mixin m { font-weight: 400; }\n.y { font-family: Roboto; @include outer; }`,
},
['libs/w9/c.scss:1 font-weight: 700'],
],
[
{
...heavy,
'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n.x { font-family: ${mono}; @include outer; }\n@mixin heavy { font-weight: 400; }`,
},
imported,
],
[
{
...heavy,
'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n@mixin heavy { font-weight: 400; }\n.x { font-family: ${mono}; @include outer; }`,
},
[],
],
// Where it is included both before and after a local definition,
// each include runs its own.
[
{
...heavy,
'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n.x { font-family: ${mono}; @include outer; }\n@mixin heavy { font-weight: 400; }\n.y { font-family: Roboto; @include outer; }`,
},
imported,
],
[
{
'libs/w9/_firm.scss':
'@mixin heavy { font-weight: 700 !important; }',
'libs/w9/c.scss': `@use 'firm' as *;\n@mixin outer { @include heavy; }\n.x { font-family: Roboto; @include outer; }\n@mixin heavy { font-weight: 400; }\n.y { font-family: ${mono}; @include outer; }`,
},
[],
],
[
{
'libs/w9/c.scss': `@mixin w { @content; font-weight: 400; }\n@mixin outer { @include w { font-weight: 700; } }\n.x { font-family: ${mono}; @include outer; }\n@mixin w { font-weight: 400; @content; }\n.y { font-family: Roboto; @include outer; }`,
},
[],
],
// A call in a definition that does not run passes nothing.
[
{
'libs/w9/c.scss': `@mixin w($weight) { font-weight: $weight; }\n@mixin m { @include w($weight: 700); }\n@mixin outer { @include m; }\n.x { font-family: Roboto; @include outer; }\n@mixin m { @include w($weight: 400); }\n.y { font-family: ${mono}; @include outer; }`,
},
[],
],
// Included from another module, once its own module has run.
[
{
...heavy,
'libs/w9/_c.scss':
"@use 'type' as *;\n@mixin outer { @include heavy; }\n@mixin heavy { font-weight: 400; }",
'libs/w9/d.scss': `@use 'c'; .x { font-family: ${mono}; @include c.outer; }`,
},
[],
],
[
{
...heavy,
'libs/w9/_c.scss':
"@use 'type' as *;\n@mixin outer { @include heavy; }",
'libs/w9/d.scss': `@use 'c'; .x { font-family: ${mono}; @include c.outer; }`,
},
imported,
],
// A content block goes where that definition places `@content`.
[
{
'libs/w9/c.scss': `@mixin w { @content; font-weight: 500; } .x { font-family: ${mono}; @include w { font-weight: 700; } } @mixin w { font-weight: 500; @content; }`,
},
[],
],
// Another module sees the last definition only.
[
{
'libs/w9/_t.scss': `@mixin m { font-family: ${mono}; }\n@mixin m { font-weight: 700; }`,
'libs/w9/c.scss': "@use 't'; .x { @include t.m; }",
},
[],
],
[
{
'libs/w9/_t.scss':
'@mixin m { font-weight: 700; }\n@mixin m { font-weight: 400; }',
'libs/w9/c.scss': `@use 't'; .x { font-family: ${mono}; @include t.m; }`,
},
[],
],
[
{
'libs/w9/_t.scss':
'@mixin m { font-weight: 400; }\n@mixin m { font-weight: 700; }',
'libs/w9/c.scss': `@use 't'; .x { font-family: ${mono}; @include t.m; }`,
},
['libs/w9/_t.scss:2 font-weight: 700'],
],
[
{
'libs/w9/_p.scss':
'@mixin m { font-weight: 700 !important; }\n@mixin outer { @include m; }\n.p { @include outer; }\n@mixin m { font-weight: 400; }',
'libs/w9/d.scss': `@use 'p'; .x { font-family: ${mono}; @include p.outer; }`,
},
[],
],
]) {
assert.deepEqual(workspace(files), expected, JSON.stringify(files));
}
});
test('caps a parameter only with what calls in JetBrains Mono rules pass', () => {
const mono = "'JetBrains Mono'";
const w = (fallback) => ({
'libs/w10/_m.scss': `@mixin w($weight: ${fallback}) { font-weight: $weight; }`,
});
const hops = {
'libs/w10/_p.scss':
'@mixin inner($weight) { font-weight: $weight; }\n@mixin outer($w: 400) { @include inner($weight: $w); }',
};
for (const [files, expected] of [
// A call in a Roboto rule passes its own weight.
[
{
...w(400),
'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w($weight: 400); }\n.b { font-family: Roboto; @include m.w($weight: 700); }`,
},
[],
],
[
{
'libs/w10/c.scss': `@mixin w($weight: 400) { font-weight: $weight; }\n.a { font-family: ${mono}; @include w($weight: 400); }\n.b { font-family: Roboto; @include w($weight: 700); }`,
},
[],
],
// Its default counts where a JetBrains Mono call leaves it out.
[
{
...w(700),
'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w($weight: 400); }\n.b { font-family: Roboto; @include m.w; }`,
},
[],
],
[
{
...w(700),
'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w; }\n.b { font-family: Roboto; @include m.w($weight: 400); }`,
},
['libs/w10/_m.scss:1 $weight: 700'],
],
// Through a mixin that passes its own parameter on.
[
{
...hops,
'libs/w10/c.scss': `@use 'p';\n.a { font-family: ${mono}; @include p.outer($w: 400); }\n.b { font-family: Roboto; @include p.outer($w: 700); }`,
},
[],
],
[
{
...hops,
'libs/w10/c.scss': `@use 'p';\n.a { font-family: ${mono}; @include p.outer($w: 700); }\n.b { font-family: Roboto; @include p.outer($w: 400); }`,
},
['libs/w10/c.scss:2 $w: 700'],
],
// A call's family named through a variable, resolved.
[
{
...w(400),
'libs/w10/c.scss': `@use 'm';\n:root { --f: ${mono}; }\n.a { font-family: var(--f); @include m.w($weight: 700); }`,
},
['libs/w10/c.scss:3 $weight: 700'],
],
[
{
...w(400),
'libs/w10/c.scss': `@use 'm';\n:root { --f: Roboto; }\n.a { font-family: var(--f); @include m.w($weight: 700); }\n.b { font-family: ${mono}; @include m.w($weight: 400); }`,
},
[],
],
// Or once a keyframe that sets another family for a while ends.
[
{
...w(400),
'libs/w10/c.scss': `@use 'm';\n:root { --f: ${mono}; }\n@keyframes swap { from { font-family: Roboto; } }\n.a { font-family: var(--f); animation: swap 1s; @include m.w($weight: 700); }`,
},
['libs/w10/c.scss:4 $weight: 700'],
],
]) {
assert.deepEqual(workspace(files), expected, JSON.stringify(files));
}
});
test("inherits the document root's family", () => {
const mono = "'JetBrains Mono'";
const media = '@media (min-width: 1px)';
+234 -39
View File
@@ -33,6 +33,8 @@ export function fontNamespaceRule(blocks, { scope, scopes }) {
/** At-rules whose body styles the enclosing rule's own element. */
const SAME_ELEMENT = /^@(?:media|supports|container|layer|include)\b/i;
const NONE = Object.freeze({ mono: false, refs: [] });
/** A family met in another module, which the scan of this one cannot read. */
const OUTSIDE = Object.freeze({ mono: false, refs: [] });
/**
* A declaration's whole value, to its `;`: a family list is comma-separated.
@@ -915,13 +917,116 @@ export function keyOrder(a, b) {
* `monoAt(index)`: the family in effect for a declaration there, from the
* innermost rule that sets one and that the declaration's rule inherits
* from. `@font-face` describes a face, so nothing in it is capped.
*
* Another module's mixin lands too: `included` gives, by the position of
* each `@include` of one, the families its top-level declarations set
* (`{ key, entry }`, `key` its place in that mixin), and `elsewhere` names
* this file's module mixins that another module includes, which style
* nothing where they are written (see `scanWorkspace`).
*/
export function familiesOf(
lexed,
blocks,
{ inString, placeOf, refsIn, rulesOf, transient = true }
{ inString, placeOf, refsIn, rulesOf, transient = true },
{ included = new Map(), elsewhere = new Set() } = {}
) {
const family = new Map();
// This file's mixins, in source order, with the scope each is declared
// in (`null` for the module; one declared in a rule is local to it).
const mixins = blocks.filter(
(b) => b.kind === 'callable' && /^@mixin\b/i.test(b.prelude)
);
const declaredIn = new Map(
mixins.map((b) => [b.start, placeOf(blocks, b.start).scope])
);
const named = (name, scope) =>
mixins.filter(
(b) => b.name === name && declaredIn.get(b.start) === scope
);
// The outermost callable body between `scope` and `site`, if any: a
// site in one runs where that callable is included, not where it is.
const deferredBy = (scope, site) =>
blocks.find(
(b) =>
b.kind === 'callable' &&
b.start < site &&
site < b.end &&
(scope === null || b.start > scope)
) ?? null;
// When a site runs, as places in the module's own run: itself outside
// any mixin body, else where each `@include` of the mixin it sits in
// runs, and the module's end (`Infinity`) where another module includes
// it or nothing here does.
const runsAt = (site, seen = new Set()) => {
const callable = deferredBy(null, site);
if (!callable) return [site];
if (seen.has(callable.start)) return [];
const scope = declaredIn.get(callable.start);
if (scope === undefined) return [Infinity];
const next = new Set([...seen, callable.start]);
const sites = (includes.get(callable.name) ?? []).filter((t) =>
placeOf(blocks, t).scopes.includes(scope)
);
const outside =
elsewhere.has(memberOf(callable.start)) ||
(sites.length === 0 && scope === null);
return [
...sites.flatMap((t) => runsAt(t, next)),
...(outside ? [Infinity] : []),
];
};
// The definition of `name` an `@include` at `site` runs when the module
// has run to `point` (see `runsAt`), as Sass resolves it: in the
// innermost scope around the site that has declared one by then, the
// last; in a mixin body declared in a rule or another mixin, any.
const resolveAt = (name, site, point) => {
for (const scope of placeOf(blocks, site).scopes) {
const declared = named(name, scope);
if (declared.length === 0) continue;
const deferred = deferredBy(scope, site) !== null;
if (deferred && scope !== null) return declared;
const by = deferred ? point : site;
const ran = declared.filter((b) => b.start < by).slice(-1);
if (ran.length > 0) return ran;
}
return [];
};
// The definitions an `@include` of `name` at `site` can run, wherever
// it runs (`defs`), and whether somewhere none of this file's is in
// scope (`open`), so one another module brings in runs.
const definitions = new Map();
const definitionsAt = (name, site) => {
const id = `${name} ${site}`;
if (!definitions.has(id)) {
const each = runsAt(site).map((point) =>
resolveAt(name, site, point)
);
definitions.set(id, {
defs: [...new Set(each.flat())],
open: each.some((found) => found.length === 0),
});
}
return definitions.get(id);
};
// Whether the definitions a landing went through (`{ name, site, def }`,
// `def` `null` for another module's) are the ones that run when the
// module has run to `point`.
const ranAt = (checks, point) =>
checks.every(({ name, site, def }) => {
const found = resolveAt(name, site, point);
return def === null ? found.length === 0 : found.includes(def);
});
// A module mixin's name, for its last module-level definition: the one
// another module includes (one declared in a rule never is).
const memberOf = (scope) => {
const block = mixins.find((b) => b.start === scope);
if (!block) return null;
const last = mixins
.filter((b) => b.name === block.name)
.filter((b) => declaredIn.get(b.start) === null)
.at(-1);
return last === block ? block.name : null;
};
// This file's `@include` sites, by mixin: a declaration in a mixin's
// body lands in the rule that includes it, at the `@include`.
const includes = new Map();
@@ -931,12 +1036,14 @@ export function familiesOf(
if (!includes.has(name)) includes.set(name, []);
includes.get(name).push(match.index);
}
// Only a mixin is included (a function's body sets nothing), where the
// name runs this definition of it.
const sitesOf = (scope) => {
const block = blocks.find((b) => b.start === scope);
// Only a mixin is included (a function's body sets nothing).
return block?.kind === 'callable'
? (includes.get(block.name) ?? [])
: [];
const block = mixins.find((b) => b.start === scope);
if (!block) return [];
return (includes.get(block.name) ?? []).filter((site) =>
definitionsAt(block.name, site).defs.includes(block)
);
};
// A content block (`@include m { … }`) passed to a mixin of this file
// that places `@content` at its top level is the including rule's
@@ -945,24 +1052,24 @@ export function familiesOf(
// `@media`) is not traced, as the mixin's own nested blocks are not.
const contentOf = (scope) => {
const block = blocks.find((b) => b.start === scope);
const named = /^@include\s+([\w-]+)/i.exec(block?.prelude ?? '');
const named = /^@include\s+([\w-]+)(?![\w.-])/i.exec(
block?.prelude ?? ''
);
if (!named) return null;
const name = named[1].replace(/_/g, '-');
const places = blocks
.filter((b) => b.kind === 'callable' && b.name === name)
.filter((b) => /^@mixin\b/i.test(b.prelude))
.flatMap((mixin) =>
[...lexed.text.slice(mixin.start, mixin.end).matchAll(CONTENT)]
.map((match) => mixin.start + match.index)
.filter((index) => !inString(index))
.filter(
(index) => placeOf(blocks, index).scope === mixin.start
)
);
const site = Math.max(
...(includes.get(name) ?? []).filter((index) => index < scope)
);
return places.length && Number.isFinite(site) ? { places, site } : null;
if (!Number.isFinite(site)) return null;
// Each place with the definition it sits in, as that runs there.
const places = definitionsAt(name, site).defs.flatMap((mixin) =>
[...lexed.text.slice(mixin.start, mixin.end).matchAll(CONTENT)]
.map((match) => mixin.start + match.index)
.filter((index) => !inString(index))
.filter((index) => placeOf(blocks, index).scope === mixin.start)
.map((index) => ({ index, check: { name, site, def: mixin } }))
);
return places.length ? { places, site } : null;
};
// Cascade layers in declared order within their parent layer: as
// `@layer a, b;` names them, or as a `@layer name { … }` block first
@@ -1128,22 +1235,33 @@ export function familiesOf(
(run) => transient || run.hold
)
: [];
const landingsOf = (scope, at, path = [], inner = []) => {
const landingsOf = (scope, at, path = [], inner = [], checks = []) => {
if (path.includes(scope)) return [];
const next = [...path, scope];
const key = [at, ...inner];
// Outside any mixin body it runs at `at`, where each definition it
// went through must be the one that runs; a module mixin runs for
// another module once the module has run (`external`).
if (!deferredBy(null, at) && !ranAt(checks, at)) return [];
const own = {
...{ at, scope, rules: rulesOf(scope), key },
...(memberOf(scope) === null
? {}
: { external: ranAt(checks, Infinity) }),
};
// A keyframe's declarations meet each other in its step, and run
// from elsewhere too (another file, unseen here).
const frames = scope === null ? null : framesOf(scope);
if (frames) {
return [
{ at, scope, rules: rulesOf(scope), key },
own,
...runsOf(frames).flatMap(({ index: site }) =>
landingsOf(
placeOf(blocks, site).scope,
site,
next,
key
key,
checks
).map((landing) => ({ ...landing, animated: true }))
),
];
@@ -1151,23 +1269,45 @@ export function familiesOf(
const content = contentOf(scope);
if (content) {
const { places, site } = content;
return places.flatMap((place) =>
landingsOf(placeOf(blocks, site).scope, site, next, [
place,
...key,
])
return places.flatMap(({ index: place, check }) =>
landingsOf(
placeOf(blocks, site).scope,
site,
next,
[place, ...key],
[...checks, check]
)
);
}
const block = mixins.find((b) => b.start === scope);
return [
{ at, scope, rules: rulesOf(scope), key },
own,
...sitesOf(scope).flatMap((site) =>
landingsOf(placeOf(blocks, site).scope, site, next, key)
landingsOf(placeOf(blocks, site).scope, site, next, key, [
...checks,
{ name: block.name, site, def: block },
])
),
...extendersOf(scope).flatMap((extender) =>
landingsOf(extender.scope, at, next, inner)
landingsOf(extender.scope, at, next, inner, checks)
),
];
};
// Where a declaration at `key` in another module's mixin lands through
// the `@include` at `site`: as one written there, its key that place's
// followed by `key`.
const landingsAt = (site, key) => {
// A bare name lands another module's only where none of this file's
// runs there.
const bare = /^@include\s+([\w-]+)(?![\w.-])/i.exec(
lexed.text.slice(site, site + 256)
);
const checks = bare
? [{ name: bare[1].replace(/_/g, '-'), site, def: null }]
: [];
return landingsOf(placeOf(blocks, site).scope, site, [], key, checks);
};
// Each declaration, where it applies, in source order.
const applied = [];
for (const match of lexed.text.matchAll(FONT_FAMILY)) {
@@ -1230,6 +1370,27 @@ export function familiesOf(
});
}
}
for (const [site, families] of included) {
for (const { key, entry } of families) {
for (const landing of landingsAt(site, key)) {
applied.push({
...landing,
entry: landing.animated
? { ...entry, animated: true }
: entry,
});
}
}
}
// What each module mixin sets at its top level, for the modules that
// include it: its own declarations and those landing in it.
const exported = new Map();
for (const { scope, key, entry, external } of applied) {
const name = memberOf(scope);
if (name === null || external === false) continue;
if (!exported.has(name)) exported.set(name, []);
exported.get(name).push({ key, entry });
}
// Keyed by rule, so a later block with one of its selectors wins.
applied.sort((a, b) => keyOrder(a.key, b.key));
// Where each rule's family was set, for the cascade between rules.
@@ -1520,41 +1681,57 @@ export function familiesOf(
// family where it lands: each rule that includes or extends it (whose
// own later family wins), of those `keep` accepts (where the weight is
// in effect). A mixin's body, or a placeholder (`%x`), styles nothing
// where it is written.
const familyAt = (index, keep, seen = new Set()) => {
// where it is written; one another module includes meets the family
// there instead.
const familyAt = (index, keep, seen = new Set(), checks = []) => {
const place = placeOf(blocks, index);
const scope = fontNamespaceRule(blocks, place) ?? place.scope;
if (seen.has(scope)) return [];
seen.add(scope);
// Read where it runs, if the definitions on the way run there (see
// `landingsOf`); a mixin body included nowhere, once the module ran.
const runs = ranAt(checks, deferredBy(null, index) ? Infinity : index);
// A content block's declaration meets the family at its `@include`,
// a keyframe's where a rule runs it.
const content = contentOf(scope);
if (content) return familyAt(content.site, keep, seen);
if (content) return familyAt(content.site, keep, seen, checks);
const frames = scope === null ? null : framesOf(scope);
if (frames) {
if (!frames.live) return [];
return [
...(keep(scope) ? [lookup(index)] : []),
...(keep(scope) && runs ? [lookup(index)] : []),
...runsOf(frames).flatMap(({ index: site }) =>
familyAt(site, keep, seen)
familyAt(site, keep, seen, checks)
),
];
}
const block = blocks.find((b) => b.start === scope);
const includes = sitesOf(scope);
const extended = extendersOf(scope).map((extender) => extender.index);
// Where another module includes it, it meets families unseen here.
const outside =
elsewhere.has(memberOf(scope)) && ranAt(checks, Infinity);
const silent =
includes.length > 0 ||
elsewhere.has(memberOf(scope)) ||
(extended.length > 0 && /^%/.test(block?.prelude ?? ''));
return [
...(silent || !keep(scope) ? [] : [lookup(index)]),
...[...includes, ...extended].flatMap((site) =>
familyAt(site, keep, seen)
...(silent || !keep(scope) || !runs ? [] : [lookup(index)]),
...(outside ? [OUTSIDE] : []),
...includes.flatMap((site) =>
familyAt(site, keep, seen, [
...checks,
{ name: block.name, site, def: block },
])
),
...extended.flatMap((site) => familyAt(site, keep, seen, checks)),
];
};
const monoAt = (index, keep = () => true) => {
const found = familyAt(index, keep).filter((entry) => entry !== NONE);
const found = familyAt(index, keep).filter(
(entry) => entry !== NONE && entry !== OUTSIDE
);
return (
found.find((entry) => entry.mono) ??
found.find((entry) => entry.refs.length > 0) ??
@@ -1562,6 +1739,19 @@ export function familiesOf(
NONE
);
};
// The families an `@include` at `site` meets, wherever it lands:
// whether one renders JetBrains Mono (`mono`, also when the mixin it
// sits in lands in another module, unseen here), and those named
// through variables (`families`), resolved later.
monoAt.callAt = (site) => {
const found = familyAt(site, () => true);
return {
mono: found.some((entry) => entry.mono || entry === OUTSIDE),
families: found.filter(
(entry) => !entry.mono && entry.refs.length > 0
),
};
};
// Where a declaration at `index` lands (`{ at, scope, rules }`, see
// `landingsOf`), for the weights in effect.
// Whether a rule runs a keyframe that sets a font only for a while,
@@ -1576,5 +1766,10 @@ export function familiesOf(
const scope = fontNamespaceRule(blocks, place) ?? place.scope;
return scope === null ? [] : landingsOf(scope, index);
};
monoAt.landingsAt = landingsAt;
monoAt.memberOf = memberOf;
monoAt.definitionsAt = definitionsAt;
// Each module mixin's top-level families, by name (`{ key, entry }`).
monoAt.exported = exported;
return monoAt;
}
+24 -1
View File
@@ -317,7 +317,30 @@ export function sassScopes(scans) {
.filter((edge) => edge.rule === 'import')
.map(({ loaded, index }) => ({ loaded, index }));
return { qualified, unqualified, imports, loadsOf };
/**
* Whether a call written in `caller` reaches `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 reaches = ({ 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
)
);
};
return { qualified, unqualified, imports, loadsOf, reaches };
}
/**