mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
e8b181fcea
commit
040acbd976
5 files changed
+1270
-142
No files matched your search
@@ -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
@@ -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:');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user