diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 7b57fb1d6..1e6ebff09 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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//.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 diff --git a/tools/nx/check-font-weights.mjs b/tools/nx/check-font-weights.mjs index c112d133d..2f26d2b26 100644 --- a/tools/nx/check-font-weights.mjs +++ b/tools/nx/check-font-weights.mjs @@ -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:'); diff --git a/tools/nx/check-font-weights.test.mjs b/tools/nx/check-font-weights.test.mjs index 84b4fd1fc..79eecf32b 100644 --- a/tools/nx/check-font-weights.test.mjs +++ b/tools/nx/check-font-weights.test.mjs @@ -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)'; diff --git a/tools/nx/font-weight-family.mjs b/tools/nx/font-weight-family.mjs index 2732c64dd..a369a9a96 100644 --- a/tools/nx/font-weight-family.mjs +++ b/tools/nx/font-weight-family.mjs @@ -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; } diff --git a/tools/nx/font-weight-scope.mjs b/tools/nx/font-weight-scope.mjs index add412ed8..b024ee7bd 100644 --- a/tools/nx/font-weight-scope.mjs +++ b/tools/nx/font-weight-scope.mjs @@ -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 }; } /**