From e8b181fcea72cb6dce92625265307d6a3db19ea4 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:55:23 +0200 Subject: [PATCH] fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780) Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render real semibold and bold faces instead of a synthetic bold, keep in step with the UI language, and move every font weight onto the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard LIVE badge now uses the interface font at 700). Add the `styles:font-weights:validate` ratchet guard and its CI step. It reads stylesheets much as Sass and the browser do (cascade, layers, mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`, keyframes) and lists what it deliberately does not trace in its header. Co-Authored-By: Claude Opus 5.5 --- .changes/ui-font-weights.md | 6 + .codex/skills/iptvnator-theme-style/SKILL.md | 8 + .github/workflows/ci.yml | 3 + apps/web/src/app/app.component.spec.ts | 11 +- apps/web/src/app/app.component.ts | 3 + .../app/services/document-language.spec.ts | 66 + .../web/src/app/services/document-language.ts | 46 + .../settings-about-section.component.ts | 2 +- .../settings-search-results.component.scss | 2 +- .../src/app/settings/settings.component.scss | 6 +- apps/web/src/index.html | 2 +- apps/web/src/styles.scss | 16 +- docs/architecture/iptvnator-ui-guidelines.md | 26 + .../src/lib/download-library.component.scss | 4 +- .../downloaded-series-dialog.component.scss | 4 +- .../feature/src/lib/downloads.component.scss | 8 +- .../category-view.component.scss | 2 +- .../grid-list/grid-list.component.scss | 4 +- .../playlist-error-view.component.scss | 2 +- .../portal-empty-state.component.scss | 2 +- .../search-layout.component.scss | 4 +- .../unified-collection-page.component.scss | 2 +- .../feature/src/lib/styles/detail-view.scss | 2 +- .../portal-channels-list.component.scss | 2 +- .../recently-added.component.scss | 2 +- .../all-channels-view.component.scss | 2 +- .../channel-details-dialog.component.scss | 6 +- .../channel-list-container.component.scss | 2 +- .../favorites-view.component.scss | 2 +- .../groups-view/groups-view.component.scss | 4 +- .../recent-view/recent-view.component.scss | 2 +- .../vod-sources-menu.component.scss | 2 +- .../playback-diagnostic-panel.component.scss | 12 +- .../player-controls.component.scss | 2 +- .../player-settings-panel.component.scss | 2 +- libs/ui/styles/_account-dialog.scss | 6 +- .../lib/rails/dashboard-rail.component.scss | 8 - .../global-favorites-list.component.scss | 2 +- .../workspace-command-palette.component.scss | 4 +- ...space-context-category-view.component.scss | 2 +- ...orkspace-context-error-view.component.scss | 2 +- ...e-keyboard-shortcuts-dialog.component.scss | 6 +- ...kspace-shell-import-overlay.component.scss | 2 +- package.json | 4 +- pnpm-lock.yaml | 8 - tools/nx/check-font-weights.mjs | 2213 +++++++ tools/nx/check-font-weights.test.mjs | 5750 +++++++++++++++++ tools/nx/check-stylesheet-inputs.mjs | 142 +- tools/nx/check-stylesheet-inputs.test.mjs | 67 + tools/nx/font-weight-family.mjs | 1580 +++++ tools/nx/font-weight-lexer.mjs | 859 +++ tools/nx/font-weight-scope.mjs | 387 ++ 52 files changed, 11216 insertions(+), 97 deletions(-) create mode 100644 .changes/ui-font-weights.md create mode 100644 apps/web/src/app/services/document-language.spec.ts create mode 100644 apps/web/src/app/services/document-language.ts create mode 100644 tools/nx/check-font-weights.mjs create mode 100644 tools/nx/check-font-weights.test.mjs create mode 100644 tools/nx/font-weight-family.mjs create mode 100644 tools/nx/font-weight-lexer.mjs create mode 100644 tools/nx/font-weight-scope.mjs diff --git a/.changes/ui-font-weights.md b/.changes/ui-font-weights.md new file mode 100644 index 000000000..4368cf717 --- /dev/null +++ b/.changes/ui-font-weights.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +Russian, Belarusian and Greek text now uses real semibold and bold letters instead of a smudged faux bold, and bold titles use a true bold face in every language, as does the dashboard's LIVE badge, now in the interface font. The app also declares its interface language, so hyphenation and capitalised labels follow that language's rules. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index a68bced22..3fd04d688 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -49,6 +49,14 @@ consumers currently use relative `@use` paths to the needed partial. - A shared change must be checked across M3U, Xtream, Stalker, workspace, portal catalog/shared UI, and unified collections where relevant. +## Typography + +- Font weights are 400, 500, 600 or 700 only, and JetBrains Mono stays at 500 + or lighter (also where a mono modifier inherits a heavier weight); + `pnpm run styles:font-weights:validate` enforces the scale, and the Mono + cap in rules that set that family. Read the + guidelines' Typography section before changing bundled fonts. + ## Validation Run the affected consumer's Nx lint/test/build target. Inspect light and dark diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e952c325e..967d2f03a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -372,6 +372,9 @@ jobs: - name: Validate electron-builder keychain password patch run: pnpm run deps:electron-builder:test + - name: Validate the font weight scale + run: pnpm run styles:font-weights:validate + - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index b66f3c6f9..3dc225196 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -1,9 +1,14 @@ +import { EventEmitter } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { Actions } from '@ngrx/effects'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { TranslateService } from '@ngx-translate/core'; +import { + DefaultLangChangeEvent, + LangChangeEvent, + TranslateService, +} from '@ngx-translate/core'; import { EpgRuntimeBridgeService, EpgService, @@ -150,7 +155,11 @@ describe('AppComponent', () => { MockProvider(TranslateService, { instant: jest.fn((key: string) => key), setDefaultLang: jest.fn(), + getDefaultLang: jest.fn(() => 'en'), use: jest.fn(), + onLangChange: new EventEmitter(), + onDefaultLangChange: + new EventEmitter(), }), // The real service imports locale chunks; the language switch // is gated on it, so it must resolve deterministically here. diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 8c61fb626..15c3df9c3 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -48,6 +48,7 @@ import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service import { PlaylistOpenRequestService } from './services/playlist-open-request.service'; import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; import { AppStartupStatusComponent } from './app-startup-status.component'; +import { syncDocumentLanguage } from './services/document-language'; const debugAppComponent = createDevLogger('AppComponent'); @@ -98,6 +99,8 @@ export class AppComponent implements OnInit { private readonly DEFAULT_LANG = Language.ENGLISH; constructor() { + syncDocumentLanguage(); + // Body-level class (like 'dark-theme') so layout adjustments also // reach content rendered outside app-root, e.g. cdk-overlay content. if (this.runtime.usesCustomWindowControls) { diff --git a/apps/web/src/app/services/document-language.spec.ts b/apps/web/src/app/services/document-language.spec.ts new file mode 100644 index 000000000..334d44f42 --- /dev/null +++ b/apps/web/src/app/services/document-language.spec.ts @@ -0,0 +1,66 @@ +import { DOCUMENT } from '@angular/common'; +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { syncDocumentLanguage, toDocumentLanguage } from './document-language'; + +@Component({ template: '' }) +class HostComponent { + constructor() { + syncDocumentLanguage(); + } +} + +describe('document language', () => { + it.each([ + ['ru', 'ru'], + ['by', 'be'], + ['zhtw', 'zh-TW'], + ['tr', 'tr'], + ['', 'en'], + [undefined, 'en'], + ])('maps %p to ', (appLanguage, expected) => { + expect(toDocumentLanguage(appLanguage)).toBe(expected); + }); + + it('follows every UI language change', () => { + TestBed.configureTestingModule({ + imports: [HostComponent, TranslateModule.forRoot()], + }); + const translate = TestBed.inject(TranslateService); + const document = TestBed.inject(DOCUMENT); + translate.setDefaultLang('en'); + + TestBed.createComponent(HostComponent); + expect(document.documentElement.lang).toBe('en'); + + translate.use('by'); + expect(document.documentElement.lang).toBe('be'); + + translate.use('tr'); + expect(document.documentElement.lang).toBe('tr'); + }); + + it('follows the fallback language while no language is active', () => { + // Startup seeds the fallback from the stored hint, then resets it to + // English when no settings exist, without ever calling use(). + TestBed.configureTestingModule({ + imports: [ + HostComponent, + TranslateModule.forRoot({ defaultLanguage: 'ru' }), + ], + }); + const translate = TestBed.inject(TranslateService); + const document = TestBed.inject(DOCUMENT); + + TestBed.createComponent(HostComponent); + expect(document.documentElement.lang).toBe('ru'); + + translate.setDefaultLang('en'); + expect(document.documentElement.lang).toBe('en'); + + translate.use('el'); + translate.setDefaultLang('ru'); + expect(document.documentElement.lang).toBe('el'); + }); +}); diff --git a/apps/web/src/app/services/document-language.ts b/apps/web/src/app/services/document-language.ts new file mode 100644 index 000000000..57ba672b2 --- /dev/null +++ b/apps/web/src/app/services/document-language.ts @@ -0,0 +1,46 @@ +import { DOCUMENT } from '@angular/common'; +import { DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TranslateService } from '@ngx-translate/core'; +import { merge } from 'rxjs'; + +/** UI language codes that are not valid BCP 47 tags. */ +const DOCUMENT_LANGUAGE_OVERRIDES: Readonly> = { + by: 'be', + zhtw: 'zh-TW', +}; + +/** + * Maps an app language code to the value for ``. The browser uses + * it for font fallback (Han variants), hyphenation and locale-aware + * `text-transform` (Turkish dotted İ, Greek accents in uppercase labels). + */ +export function toDocumentLanguage(appLanguage?: string | null): string { + const code = appLanguage?.trim(); + if (!code) { + return 'en'; + } + return DOCUMENT_LANGUAGE_OVERRIDES[code] ?? code; +} + +/** + * Keeps `` in step with the language the UI renders, whichever + * code path switches it (startup, settings). With no active language the UI + * renders the fallback: startup seeds it from the stored hint and then resets + * it to English when no settings exist, without an active-language change. + * Must run in an injection context. + */ +export function syncDocumentLanguage(): void { + const document = inject(DOCUMENT); + const translate = inject(TranslateService); + const apply = (): void => { + document.documentElement.lang = toDocumentLanguage( + translate.currentLang || translate.getDefaultLang() + ); + }; + + apply(); + merge(translate.onLangChange, translate.onDefaultLangChange) + .pipe(takeUntilDestroyed(inject(DestroyRef))) + .subscribe(apply); +} diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 0065b8b02..9d6f1f6a0 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -40,7 +40,7 @@ import { UpdateChannelOption } from './settings.models'; '.app-update-channel { margin-top: 12px; }', '.app-update-channel mat-form-field { width: 100%; max-width: 320px; }', '.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }', - '.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 650; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }', + '.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }', '.app-update-status--stale strong, .app-update-status--stale .app-update-status__channel { opacity: 0.55; }', ], }) diff --git a/apps/web/src/app/settings/settings-search-results.component.scss b/apps/web/src/app/settings/settings-search-results.component.scss index 7e5f8ebd7..130d33824 100644 --- a/apps/web/src/app/settings/settings-search-results.component.scss +++ b/apps/web/src/app/settings/settings-search-results.component.scss @@ -55,7 +55,7 @@ li:first-child .settings-search-result { .settings-search-result__label { font-size: 0.95rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; color: var(--app-heading-color); } diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index e02e8e917..96b94a1d4 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -176,7 +176,7 @@ h4 { margin: 0; font-size: 0.95rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; } @@ -233,7 +233,7 @@ strong { color: var(--app-body-color); font-size: 0.9rem; - font-weight: 650; + font-weight: 600; } progress { @@ -620,7 +620,7 @@ strong { font-size: 0.92rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; } diff --git a/apps/web/src/index.html b/apps/web/src/index.html index 508eb3c9b..ac8402f64 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -1,5 +1,5 @@ - + IPTVnator diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index c0c09dafb..9afd95269 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -15,17 +15,25 @@ @import 'material-design-icons-iconfont/dist/material-design-icons.css'; // ─── Bundled Fonts ──────────────────────────────────────────────────────────── +// DM Sans ships only Latin glyphs, so Cyrillic and Greek text (ru, by, el) +// falls back to Roboto. Both load every weight on the UI scale +// (400/500/600/700, enforced by `pnpm run styles:font-weights:check`): a 600 +// or 700 that finds nothing heavier than 500 gets Chromium's synthetic bold. +// Load whole weights, not the single-script files: those carry no +// unicode-range, so a Cyrillic-only 600 face would win the weight match for +// Latin text in `Roboto, …` stacks and push it onto the next family. +// JetBrains Mono stops at 500: its 600/700 faces would add more than the +// initial-bytes ratchet allows, so monospace text stays at 400/500. @import '@fontsource/dm-sans/400.css'; @import '@fontsource/dm-sans/500.css'; @import '@fontsource/dm-sans/600.css'; -@import '@fontsource/roboto/300.css'; +@import '@fontsource/dm-sans/700.css'; @import '@fontsource/roboto/400.css'; @import '@fontsource/roboto/500.css'; +@import '@fontsource/roboto/600.css'; +@import '@fontsource/roboto/700.css'; @import '@fontsource/jetbrains-mono/400.css'; @import '@fontsource/jetbrains-mono/500.css'; -@import '@fontsource/crimson-pro/400.css'; -@import '@fontsource/crimson-pro/600.css'; -@import '@fontsource/crimson-pro/700.css'; // ─── Shared Components ──────────────────────────────────────────────────────── diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 382f9b2a1..7b57fb1d6 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -892,6 +892,32 @@ Prefer removing a control over shrinking everything around it: Never drop the only way back to a hidden surface. A collapse toggle that is reachable by touch needs its restore affordance to be reachable too. +## Typography + +The app stack is DM Sans with Roboto behind it (`$app-font-stack` in +`apps/web/src/m3-theme.scss`). DM Sans covers Latin only, so Cyrillic and Greek +UI text (ru, by, el) renders in Roboto. `apps/web/src/styles.scss` bundles both +families in 400, 500, 600 and 700, and JetBrains Mono in 400 and 500 (its +heavier faces would exceed the initial-bytes ratchet). + +- Use only those four weights: in `font-weight`, in the `font` shorthand, and in + any custom property, Sass variable or token map that feeds one. A weight + between faces snaps to a neighbour (650 renders as 700), and 600 or more with + no face of at least 600 gets Chromium's synthetic bold. CI runs + `pnpm run styles:font-weights:validate`, which rejects any other value. +- 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. +- 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 + to the next family. +- `` follows the UI language (`syncDocumentLanguage()` maps `by` to + `be` and `zhtw` to `zh-TW`), so `hyphens`, `text-transform` (Turkish İ) and + Han glyph fallback use the right locale. + ## Theme Guidance ### Light Theme diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index f6ef58daf..fed710064 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -192,7 +192,7 @@ background: transparent; color: var(--app-heading-color); font-size: 0.93rem; - font-weight: 650; + font-weight: 600; line-height: 1.3; text-align: left; transition: color 140ms ease; @@ -218,7 +218,7 @@ padding: 8px 4px; background: transparent; color: var(--mat-sys-primary); - font-weight: 650; + font-weight: 600; } .download-library__episode-label { diff --git a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss index ffb1d147b..2bb439f23 100644 --- a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss @@ -33,7 +33,7 @@ .downloaded-series-dialog__eyebrow { color: var(--app-eyebrow-color); font-size: 0.68rem; - font-weight: 750; + font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; } @@ -93,7 +93,7 @@ mat-dialog-content { color: var(--mat-sys-primary); font-family: var(--mat-sys-label-small-font); font-size: 0.73rem; - font-weight: 750; + font-weight: 700; text-align: center; } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.scss b/libs/portal/downloads/feature/src/lib/downloads.component.scss index 6595374c3..7406dab80 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloads.component.scss @@ -53,7 +53,7 @@ h1 { margin: 0; font-size: clamp(1.25rem, 1.8vw, 1.6rem); - font-weight: 680; + font-weight: 600; line-height: 1.15; letter-spacing: -0.035em; } @@ -86,7 +86,7 @@ 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; font-variant-numeric: tabular-nums; - font-weight: 650; + font-weight: 500; } } @@ -188,7 +188,7 @@ background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); font: inherit; font-size: 0.79rem; - font-weight: 620; + font-weight: 600; white-space: nowrap; cursor: pointer; transition: @@ -271,7 +271,7 @@ margin: 0; color: var(--app-heading-color, var(--mat-sys-on-surface)); font-size: 1rem; - font-weight: 670; + font-weight: 600; letter-spacing: -0.015em; } diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss index d80496297..b9fb67f97 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.scss @@ -44,7 +44,7 @@ mat-card-content { --error-empty-illustration-size: clamp(132px, 15vw, 180px); --error-empty-max-width: min(270px, 100%); --error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem); - --error-empty-title-weight: 610; + --error-empty-title-weight: 600; --error-empty-title-spacing: -0.006em; --error-empty-copy-gap: 4px; --error-empty-description-opacity: 0.82; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index 3a8ef2f6d..2c3e9c7f6 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -193,7 +193,7 @@ mat-card { --error-empty-max-width: min(480px, 100%); --error-empty-title-size: clamp(1.45rem, 2vw, 1.9rem); --error-empty-description-size: clamp(0.95rem, 1.15vw, 1.05rem); - --error-empty-title-weight: 660; + --error-empty-title-weight: 600; --error-empty-title-spacing: -0.014em; --error-empty-copy-gap: 10px; --error-empty-description-line-height: 1.44; @@ -211,7 +211,7 @@ mat-card { --error-empty-max-width: 310px; --error-empty-title-size: 1.3rem; --error-empty-description-size: 0.9rem; - --error-empty-title-weight: 640; + --error-empty-title-weight: 600; --error-empty-copy-gap: 8px; } } diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.scss b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.scss index f10e66a92..455b48d75 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.scss +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.scss @@ -12,7 +12,7 @@ --error-view-copy-width: var(--error-empty-max-width, 420px); --error-view-copy-gap: var(--error-empty-copy-gap, 8px); --error-view-title-size: var(--error-empty-title-size, 1.55rem); - --error-view-title-weight: var(--error-empty-title-weight, 640); + --error-view-title-weight: var(--error-empty-title-weight, 600); --error-view-title-spacing: var(--error-empty-title-spacing, -0.012em); --error-view-description-size: var(--error-empty-description-size, 0.98rem); --error-view-description-line-height: var( diff --git a/libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss b/libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss index d712c4f37..b8398a31f 100644 --- a/libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss +++ b/libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss @@ -27,7 +27,7 @@ .empty-state-title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin: 0; opacity: 0.6; diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss index 61df3c9ad..39286e9ba 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss @@ -68,7 +68,7 @@ margin: 0; white-space: nowrap; font-size: 1.2rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.02em; } @@ -135,7 +135,7 @@ h3 { margin: 0 0 0.5rem; font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; color: var(--text-color); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss index ee656f062..172472657 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss @@ -150,7 +150,7 @@ app-unified-live-tab { .empty-title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin: 0; } diff --git a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss index d1d98703e..589b5d3ab 100644 --- a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss +++ b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss @@ -2,7 +2,7 @@ @include detail-view.base( $season-title-font-size: 1.2rem, - $season-title-font-weight: 650, + $season-title-font-weight: 600, $season-title-letter-spacing: -0.02em, $episode-title-font-size: 1rem, $episode-title-letter-spacing: -0.01em diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss index 341cb6b6a..f40e533a3 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss @@ -115,7 +115,7 @@ .empty-state-title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin: 0 0 8px; text-wrap: balance; diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss index 1ea7b22f6..0fd0b35c6 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss @@ -18,7 +18,7 @@ .recently-added-page__title { margin: 0; font-size: 1.5rem; - font-weight: 650; + font-weight: 600; line-height: 1.2; letter-spacing: -0.02em; color: var(--app-heading-color, #d8dce8); diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss index e74c10efb..58bcf0f21 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss @@ -131,7 +131,7 @@ .no-results-title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin-bottom: 8px; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss index f030d5d0e..80795b10f 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss @@ -69,7 +69,7 @@ margin: 0; font-size: 1.3rem; line-height: 1.2; - font-weight: 650; + font-weight: 600; letter-spacing: -0.02em; color: var(--app-heading-color, var(--mat-sys-on-surface)); } @@ -169,7 +169,7 @@ .channel-stat__value { font-size: 1.1rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.02em; color: var(--app-heading-color, var(--mat-sys-on-surface)); overflow: hidden; @@ -179,6 +179,8 @@ &--mono { font-family: 'JetBrains Mono', 'Roboto Mono', monospace; font-size: 0.92rem; + // JetBrains Mono is bundled up to 500; the value's 600 would be faked. + font-weight: 500; } &--empty { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index 4ebd3682c..99b7873a5 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -57,7 +57,7 @@ .empty-source-state__title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; margin: 0; letter-spacing: -0.01em; } diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss index c14b2602f..1094a63d2 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss @@ -32,7 +32,7 @@ .empty-favorites { margin-top: 40px; font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index 0e62ae9a6..a9f735244 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -220,7 +220,7 @@ &__title { font-size: 0.85rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin-bottom: 4px; text-wrap: balance; @@ -310,7 +310,7 @@ .empty-state-title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin-bottom: 8px; } diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss index aefb020d3..003b42a66 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss @@ -32,7 +32,7 @@ .empty-recent { margin-top: 40px; font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; } diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss index 46ba4092d..9dc07c35e 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss @@ -41,7 +41,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); // size as the hint and the action beside it, so all three scanned as // column headers over the list. font-size: 15px; - font-weight: 650; + font-weight: 600; letter-spacing: -0.01em; color: var(--app-on-surface); overflow: hidden; diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss index 7ee9db7a5..3189d3fec 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss @@ -41,7 +41,7 @@ background: rgb(255 148 35 / 15%); color: #ffb24c; font-size: 0.78rem; - font-weight: 800; + font-weight: 700; letter-spacing: 0; line-height: 1.1; text-transform: uppercase; @@ -66,7 +66,7 @@ margin: 20px 0 0; color: #fff; font-size: 1.65rem; - font-weight: 760; + font-weight: 700; letter-spacing: 0; line-height: 1.05; } @@ -98,7 +98,7 @@ background: rgb(255 255 255 / 5%); color: rgb(255 255 255 / 68%); font-size: 0.78rem; - font-weight: 620; + font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } @@ -267,7 +267,7 @@ .web-player-diagnostic__player-label { overflow: hidden; font-size: 0.95rem; - font-weight: 760; + font-weight: 700; line-height: 1.05; text-overflow: ellipsis; white-space: nowrap; @@ -289,7 +289,7 @@ min-width: 0; color: currentColor; font-size: 0.78rem; - font-weight: 680; + font-weight: 600; line-height: 1.1; opacity: 0.82; overflow-wrap: anywhere; @@ -325,7 +325,7 @@ cursor: pointer; font: inherit; font-size: 0.86rem; - font-weight: 640; + font-weight: 600; line-height: 1.2; } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index a8ca40172..bc85bc1f6 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -196,7 +196,7 @@ overflow: hidden; color: var(--pc-text); font-size: 17px; - font-weight: 650; + font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss index 0f4469587..6dd01acb1 100644 --- a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss @@ -304,7 +304,7 @@ .player-settings__seg-item--selected { color: var(--pc-accent-violet, #b599ff); background: rgba(181, 153, 255, 0.2); - font-weight: 650; + font-weight: 600; } // The default value selected is "nothing changed": neutral, not violet. diff --git a/libs/ui/styles/_account-dialog.scss b/libs/ui/styles/_account-dialog.scss index 36a10e950..dc48d7b2a 100644 --- a/libs/ui/styles/_account-dialog.scss +++ b/libs/ui/styles/_account-dialog.scss @@ -68,7 +68,7 @@ margin: 0; font-size: 1.3rem; line-height: 1.2; - font-weight: 650; + font-weight: 600; letter-spacing: -0.02em; color: var(--app-heading-color, var(--mat-sys-on-surface)); } @@ -184,7 +184,7 @@ .account-stat__value { font-size: 1.3rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.03em; color: var(--app-heading-color, var(--mat-sys-on-surface)); overflow-wrap: anywhere; @@ -360,6 +360,8 @@ .account-chip--mono { font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + // JetBrains Mono is bundled up to 500; the chip's 600 would be faked. + font-weight: 500; text-transform: none; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 67841af2a..da025979f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -690,14 +690,6 @@ display: inline-flex; align-items: center; gap: 4px; - font-family: var( - --font-mono, - ui-monospace, - 'SF Mono', - 'JetBrains Mono', - Menlo, - monospace - ); font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss index 6cb095d90..b39baa81e 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss @@ -65,7 +65,7 @@ .empty-title { font-size: 1.05rem; - font-weight: 520; + font-weight: 500; letter-spacing: -0.01em; margin: 0 0 8px; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss index 1fe3199f0..9585c09df 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss @@ -44,7 +44,7 @@ color: var(--mat-sys-on-surface); font: inherit; font-size: 0.94rem; - font-weight: 450; + font-weight: 500; width: 100%; min-width: 0; @@ -188,7 +188,7 @@ .palette-command__label { font-size: 0.84rem; - font-weight: 530; + font-weight: 500; letter-spacing: -0.006em; white-space: nowrap; overflow: hidden; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss index 26a9bc4be..ec6c3a5f6 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.scss @@ -104,7 +104,7 @@ mat-card-content { --error-empty-illustration-size: clamp(132px, 15vw, 180px); --error-empty-max-width: min(270px, 100%); --error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem); - --error-empty-title-weight: 610; + --error-empty-title-weight: 600; --error-empty-title-spacing: -0.006em; --error-empty-copy-gap: 4px; --error-empty-description-opacity: 0.82; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-error-view.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-error-view.component.scss index 50f5f9caf..d58017efb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-error-view.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-error-view.component.scss @@ -12,7 +12,7 @@ --error-view-copy-width: var(--error-empty-max-width, 420px); --error-view-copy-gap: var(--error-empty-copy-gap, 8px); --error-view-title-size: var(--error-empty-title-size, 1.55rem); - --error-view-title-weight: var(--error-empty-title-weight, 640); + --error-view-title-weight: var(--error-empty-title-weight, 600); --error-view-title-spacing: var(--error-empty-title-spacing, -0.012em); --error-view-description-size: var(--error-empty-description-size, 0.98rem); --error-view-description-line-height: var( diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss index 84943135c..11fbc3bdb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss @@ -117,7 +117,7 @@ border: 1px solid var(--shortcut-border); border-radius: 8px; font-size: 0.76rem; - font-weight: 650; + font-weight: 600; line-height: 1; white-space: nowrap; @@ -186,7 +186,7 @@ margin: 0; color: var(--shortcut-text); font-size: 0.74rem; - font-weight: 750; + font-weight: 700; line-height: 1.2; text-transform: uppercase; } @@ -273,7 +273,7 @@ 0 1px 0 color-mix(in srgb, white 28%, transparent); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.72rem; - font-weight: 750; + font-weight: 700; line-height: 1; white-space: nowrap; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-import-overlay/workspace-shell-import-overlay.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-import-overlay/workspace-shell-import-overlay.component.scss index 4b5e993a3..35c9c9fe4 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-import-overlay/workspace-shell-import-overlay.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-import-overlay/workspace-shell-import-overlay.component.scss @@ -52,7 +52,7 @@ h3 { margin: 0; font-size: 1.55rem; - font-weight: 650; + font-weight: 600; letter-spacing: -0.03em; color: var(--app-heading-color); } diff --git a/package.json b/package.json index fcbbbd11d..77a6d364f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "deps:nx:validate": "pnpm run deps:nx:test && pnpm run deps:nx:check", "deps:vite:test": "node --test tools/dependencies/vite-transform-filter.test.mjs", "deps:electron-builder:test": "node --test tools/dependencies/app-builder-lib-keychain-password.test.mjs", + "styles:font-weights:test": "node --test tools/nx/check-font-weights.test.mjs", + "styles:font-weights:check": "node tools/nx/check-font-weights.mjs", + "styles:font-weights:validate": "pnpm run styles:font-weights:test && pnpm run styles:font-weights:check", "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", @@ -181,7 +184,6 @@ "@eslint/js": "^9.38.0", "@faker-js/faker": "10.6.0", "@fontsource-variable/bricolage-grotesque": "5.3.0", - "@fontsource/crimson-pro": "5.3.0", "@fontsource/dm-sans": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368fe28de..333c06d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,9 +254,6 @@ importers: '@fontsource-variable/bricolage-grotesque': specifier: 5.3.0 version: 5.3.0 - '@fontsource/crimson-pro': - specifier: 5.3.0 - version: 5.3.0 '@fontsource/dm-sans': specifier: 5.3.0 version: 5.3.0 @@ -2103,9 +2100,6 @@ packages: '@fontsource-variable/bricolage-grotesque@5.3.0': resolution: {integrity: sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==} - '@fontsource/crimson-pro@5.3.0': - resolution: {integrity: sha512-PXQH0NGma2wwskZAySBH+s/ezAYBwyDH0I4oRURBu9u81nNUCm5DcNCJaUS9H0UR1y8qlslLfVRU6mnDBpffrA==} - '@fontsource/dm-sans@5.3.0': resolution: {integrity: sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==} @@ -11763,8 +11757,6 @@ snapshots: '@fontsource-variable/bricolage-grotesque@5.3.0': {} - '@fontsource/crimson-pro@5.3.0': {} - '@fontsource/dm-sans@5.3.0': {} '@fontsource/ibm-plex-mono@5.3.0': {} diff --git a/tools/nx/check-font-weights.mjs b/tools/nx/check-font-weights.mjs new file mode 100644 index 000000000..c112d133d --- /dev/null +++ b/tools/nx/check-font-weights.mjs @@ -0,0 +1,2213 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { extractStylesheetLoads } from './check-stylesheet-inputs.mjs'; +import { + blocksOf, + calleeOf, + closingBrace, + codeExpression, + codeStringAt, + concatenatedAfter, + decodeSource, + inBinding, + inConditionPrelude, + inMarkupCss, + inUnquotedStyle, + insideTag, + invocationsOf, + lex, + lineIndex, + namesCallable, + placeOf, + resultsOf, + templateParts, + tokensOf, + valueAfter, +} from './font-weight-lexer.mjs'; +import { + ALL_RESET, + IMPORTANT, + startsDeclaration, + MONO_WEIGHT_CAP, + canonicalSelector, + declarationText, + atRootExcludes, + blockKind, + familiesOf, + entryVerdict, + familyEntries, + familyParts, + fontNamespaceRule, + hasLiteralSize, + parsesAsFont, + keyOrder, + levelOf, + plainHost, + reachDepth, + selectorList, + reachesEverything, + rendersMono, + shorthandFamilies, +} from './font-weight-family.mjs'; +import { + effectiveDeclarations, + exposedName, + sassScopes, +} from './font-weight-scope.mjs'; + +/** + * The weights `apps/web/src/styles.scss` bundles for DM Sans and its Roboto + * fallback (JetBrains Mono stops at 500). Any other value snaps to a + * neighbouring face (650 renders as 700), and a 600 or 700 that finds + * nothing heavier than 500 gets Chromium's synthetic bold. The workspace is + * at zero exceptions; this check keeps it there. + * + * Sass is not compiled, so a weight must be written, not computed: arithmetic + * and functions other than `var()` are findings in themselves. Every weight + * written in a declaration counts, `var()` fallbacks included: a fallback + * renders as soon as its property is unset anywhere. Definitions reached + * through variables count only where they can render. Variables and + * the callables their named arguments go to are followed by name (`-` and `_` + * alike), through `@forward … as prefix-*` and its `show`/`hide` lists too. + * A parameter default counts where a call leaves it out, or when no call is + * in sight. A weight set from code is read per value it can take, so a + * condition's numbers are not weights, a template literal as each text its + * literal `${…}` parts produce (`` `65${0}` `` is 650; a weight it builds + * around another value is computed), and CSS text that a string leaves to + * code (`'font-weight:' + w`) is read from the operand after the `+`. A + * partial's `!default` gives way where every load of it configures the + * name. A file is read as the browser reads it: CSS escapes in names + * decoded as Sass decodes them (`font-w\65 ight` is `font-weight`), + * markup's character references too (`650` is 650), and a family's + * static interpolation as Sass writes it out (`#{'Jet' + 'Brains'}` is + * `JetBrains`). Not traced: + * JavaScript escapes in TypeScript (`fontW\u0065ight`), positional mixin + * or function arguments, calls through `meta.apply`, `meta.load-css` and + * `@function` return values, so pass weights as named `$…weight` arguments; + * nor values TypeScript stores and binds later (a component field, signal or + * input read by `[style.fontWeight]="weight"`), so bind a literal or keep + * the field's literal on the scale. + * + * A stylesheet rule set in JetBrains Mono (its own `font-family` or `font`, + * written out or through variables, or one a nested rule inherits; see + * `familiesOf`) is capped at `MONO_WEIGHT_CAP`. A mixin's top-level + * declarations land where this file includes it, in the order Sass writes + * them out, a content block's too where the mixin places `@content` at its + * top level, a rule this file `@extend`s whole applies to its extenders, and + * a keyframe's declarations (of its last definition) apply where a rule's + * last `animation` runs it, over the rule's own and under `!important` ones, + * while it runs and, unless it holds a frame (`forwards`, `both`, + * `infinite`), the rule's own after. An `:is()` or `:where()` reads as the + * selectors it holds (`:where(.p) .c` is `.p .c`). A rule also sets the + * family of the narrower selectors it reaches (`.x` for `.x:hover`, `.p .x` + * for `.w .p .x:hover`), compound by compound across what its combinators + * allow; of those, the element's own rule and `*`, the cascade winner counts + * (`!important`, layer, specificity, source order; a `@layer` always + * applies, and `revert-layer` falls back past its own). Without a family of + * its own, a rule takes one from an ancestor its compiled selector names (an + * `@at-root` rule's as Sass writes it out), else from the document root + * (`:host`, `body`, `html`, `:root`) in its file; a family applies under + * conditions (`@media`, `@supports`, `@if`) that the reader shares, or + * always. Sass conditions are not evaluated: each `@if`/`@else` branch + * counts as one that may run, in a rule, a mixin or a content block alike. + * + * Not traced: global styles in another file, a weight inherited from another + * rule, a mixin from another module, a mixin's nested rules and at-rules + * (and a `@content` placed in one), a family set on an element from code, + * and one that reaches only some of a rule's elements (a more specific + * `.x.active`, or `@extend .m` into `.m.active`). Animations are read as a + * whole: a keyframe's steps cascade as one rule rather than as states in + * turn, a rule's last `animation` runs whatever an earlier `!important` one + * sets, one layer holding a frame holds all of them, and a quoted name is + * read word by word. + */ +export const WEIGHT_SCALE = Object.freeze([400, 500, 600, 700]); + +export { MONO_WEIGHT_CAP }; +const BOLD = /\bbold\b/gi; + +/** + * Not app UI: the landing site ships a variable font, so any weight is a real + * one there. + */ +const EXCLUDED_PREFIXES = [ + 'apps/website/', + // Server-side SVG artwork in system Arial, served as images. + 'apps/xtream-mock-server/', + 'libs/shared/marketing-fixtures/', +]; +const STYLESHEET = /\.(s?css)$/; +const SOURCE = /\.(ts|html|svg)$/; + +/** + * Stylesheets feed weights through `font-weight`, custom properties, Sass + * variables and Material token maps, so any name ending in `weight` counts. + * In TypeScript, HTML and SVG only CSS text is in scope, never a variable that + * happens to be called `…Weight`. CSS names, keywords and functions are + * case-insensitive, so every pattern below that matches CSS text is too. + */ +const STYLESHEET_WEIGHT = /(?'"=<`\\]+))/gi; +/** A custom property or Sass variable that a weight value may refer to. */ +const DEFINITION = /(?` or + * `` initial value. It parses as CSS does: each function takes its + * number of arguments, `+` and `-` need whitespace on both sides, and a unit, + * `%` or `var()` makes it invalid there. + */ +function numericMath(value) { + const tokens = mathTokens(value.trim()); + if (!tokens || !/\($/.test(tokens[0]?.text ?? '')) return false; + let i = 0; + const next = () => tokens[i]?.text; + const sum = () => { + if (!product()) return false; + while (next() === '+' || next() === '-') { + if (!tokens[i].space || !tokens[i + 1]?.space) return false; + i += 1; + if (!product()) return false; + } + return true; + }; + const product = () => { + if (!operand()) return false; + while (next() === '*' || next() === '/') { + i += 1; + if (!operand()) return false; + } + return true; + }; + const operand = () => { + const token = next() ?? ''; + i += 1; + if (/^[+-]?[\d.]/.test(token) || MATH_CONSTANT.test(token)) return true; + if (token === '(') return sum() && tokens[i++]?.text === ')'; + const name = /^([a-z][\w-]*)\($/i.exec(token)?.[1].toLowerCase(); + if (!name || !Object.hasOwn(MATH_ARITY, name)) return false; + if (name === 'round' && ROUNDING.test(next() ?? '')) { + if (tokens[i + 1]?.text !== ',') return false; + i += 2; + } + let count = 0; + do { + if (!sum()) return false; + count += 1; + } while (next() === ',' && ++i); + const [fewest, most] = MATH_ARITY[name]; + return tokens[i++]?.text === ')' && count >= fewest && count <= most; + }; + return operand() && i === tokens.length; +} + +/** + * Whether a `@property` body is a valid registration of `value`: it has a + * `syntax` and `inherits`, and the syntax accepts the value (`*` anything, + * `` a number, `` an integer, either a math function of + * numbers, ``/`` a name). + */ +function registrationAccepts(body, value) { + const syntax = /(?/i.test(syntax); + if (!NUMBER_TOKEN.test(value)) { + return /<(?:custom-ident|string)>/i.test(syntax); + } + return ( + //i.test(syntax) || + (INTEGER_TOKEN.test(value) && //i.test(syntax)) + ); +} + +/** A run of whitespace, or a quoted string (its spaces are its own). */ +const SPACING = /(['"])(?:\\[\s\S]|(?!\1)[^\\])*\1|\s+/g; + +/** + * Whether rules with the selector chains `a` style every element that ones + * with `b` do (see `selectorsOf` in `scanWeights`). + */ +function covers(a, b) { + return Boolean(a && b?.length) && b.every((chain) => a.includes(chain)); +} + +/** A registered custom property: `@property --x { … }`. */ +const PROPERTY_RULE = /@property\s+(--[\w-]+)\s*\{/gi; +/** Sass loops that bind variables: `@each $a, $b in …`, `@for $i from …`. */ +const EACH_LOOP = /@each\s+((?:\$[\w-]+\s*,\s*)*\$[\w-]+)\s+in\s+([^{]+)\{/gi; +const FOR_LOOP = + /@for\s+(\$[\w-]+)\s+from\s+([\s\S]+?)\s+(through|to)\s+([\s\S]+?)\s*\{/gi; +/** + * Computed code: arithmetic next to a number (`600 + 50` is 650, also with a + * signed operand as in `600 - -50`), or a minus (or a `+` before a bracket) + * in front of a number: `-(-650)`. + */ +const CODE_ARITHMETIC = + /\d\s*(?:\*\*|[-+*/%])\s*[-+]*\s*[\w$(.'"`]|[\w$).'"`]\s*(?:\*\*|[-+*/%])\s*[-+]*\s*\.?\d|(?:^|[^\w$).'"`\]\s])\s*(?:-\s*[\d.(]|\+\s*\()/; + +/** A CSS : decimals, an exponent and a `+` sign are all valid. */ +const NUMBER_TEXT = String.raw`\+?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?`; +const NUMBER = new RegExp( + String.raw`(? normalized.startsWith(prefix))) { + return false; + } + return STYLESHEET.test(normalized) || SOURCE.test(normalized); +} + +/** Comments blanked, newlines kept, so reported line numbers match the file. */ +export function blankComments(source, file = 'source.scss') { + return lex(file, source).text; +} + +/** Scale step to use instead: rounds down between 600 and 700 (620–680). */ +export function nearestScaleWeight(weight) { + if (weight < 450) return 400; + if (weight < 550) return 500; + if (weight < 700) return 600; + return 700; +} + +/** Sass treats `-` and `_` in a name alike; custom properties are exact. */ +function identity(name) { + return name.startsWith('$') ? name.replace(/_/g, '-') : name; +} + +/** + * Sass arithmetic and functions compile to a value the source does not show + * (`400 + 500` is 900, `-(-650)` is 650), so a CSS weight that uses them is + * reported whole. The one sign that is part of a number is a `+` written + * directly before it (`+700`). TypeScript `${…}` holds code, whose numbers + * are checked as terms instead. + */ +function computedIn(value) { + const css = value.replace(/\$\{[^}]*\}/g, ' 0 '); + let previous = ''; + EXPRESSION_TOKEN.lastIndex = 0; + while (EXPRESSION_TOKEN.lastIndex < css.length) { + const start = EXPRESSION_TOKEN.lastIndex; + const match = EXPRESSION_TOKEN.exec(css); + if (!match) { + EXPRESSION_TOKEN.lastIndex = start + 1; + previous = ''; + continue; + } + const [, number, identifier, call, operator, paren] = match; + if (call && identifier.toLowerCase() !== 'var') return true; + // `+700` is a number; any other sign or operator computes a value. + const signed = + operator === '+' && + previous !== 'operand' && + /^[\d.]/.test(css.slice(EXPRESSION_TOKEN.lastIndex)); + if (operator && !signed) return true; + previous = + !call && (number || identifier || paren === ')') ? 'operand' : ''; + } + return false; +} + +/** + * What a value contributes to a weight: off-scale terms as written, and the + * variables to follow. `font` reads the value as the shorthand, whose weight + * comes before the size and the family: a token counts only with two more + * after it (so a TypeScript `font: 12` property is not a weight), and never + * the size with its `/line-height` or what follows a `/`. A variable token + * may stand for any stretch of the shorthand (`font: italic $body`), so it is + * followed as shorthand, carrying how many tokens come after it (`after`). + * A weight is 1 to 1000, so `0`, the one unitless font size, is never one. + */ +function analyse(mode, value, options = {}) { + const { minimum = 1, code = false, after = 0, cap = null } = options; + if (mode === 'font') { + const tokens = tokensOf(value); + const weight = []; + const terms = []; + const references = []; + tokens.forEach((token, index) => { + // Only "two or more after" matters; capping keeps cycles finite. + const tail = Math.min(tokens.length - 1 - index + after, 2); + const variable = VARIABLE_TOKEN.exec(token); + if (variable) { + const [, custom, fallback, ns, sass, innerNs, interpolated] = + variable; + const name = identity(custom ?? sass ?? interpolated); + const namespace = ns ?? innerNs ?? null; + references.push({ + ...{ name, namespace, mode: 'font', after: tail }, + cap, + }); + const inner = analyse('font', fallback ?? '', { + after: tail, + cap, + }); + terms.push(...inner.terms); + references.push(...inner.references); + return; + } + const previous = tokens[index - 1] ?? ''; + if (tail >= 2 && !token.includes('/') && !previous.endsWith('/')) { + weight.push(token); + } + }); + const own = analyse('weight', weight.join(' '), { cap }); + return { + terms: [...terms, ...own.terms], + references: [...references, ...own.references], + }; + } + // Above `cap`, a scale weight (or `bold`, 700) gets a synthetic bold. + const heavy = (term) => + cap !== null && Number(/^bold$/i.test(term) ? 700 : term) > cap; + const numbers = [...value.matchAll(NUMBER)] + .map((match) => match[1]) + .filter((term) => Number(term) >= minimum) + .filter((term) => !WEIGHT_SCALE.includes(Number(term)) || heavy(term)); + const keywords = [ + ...(value.match(RELATIVE_KEYWORD) ?? []), + ...(value.match(BOLD) ?? []).filter(heavy), + ]; + const computed = code ? CODE_ARITHMETIC.test(value) : computedIn(value); + return { + terms: computed + ? [{ value: value.trim(), computed: true }] + : [...numbers, ...keywords].map((term) => + heavy(term) ? { value: term, cap } : { value: term } + ), + references: [...value.matchAll(REFERENCE)].map((match) => ({ + name: identity(match[1] ?? match[3]), + namespace: match[2] ?? null, + mode: 'weight', + cap, + })), + }; +} + +/** A Sass variable in a family, interpolated (`#{$x}`) or not (`ns.$x`). */ +const SASS_VALUE = + /#\{\s*((?:[\w-]+\.)?\$[\w-]+)\s*\}|((?:[\w-]+\.)?\$[\w-]+)/g; + +/** A Sass variable's value as it is substituted: no flags, no outer quotes. */ +function sassValue(value) { + return value + .replace(/!(?:default|global)\b/gi, '') + .trim() + .replace(/^(['"])(.*)\1$/, '$2'); +} + +/** + * The variables a family reads (see `familyParts`), as references resolved + * from `at` (a file, position and scope): each custom property with its + * `var()` fallback, then any Sass variable named outright. + */ +function familyRefs({ outside, vars }, at) { + const place = { + ...{ file: at.file, index: at.index, scopes: at.scopes }, + ...{ inCallable: at.inCallable, callable: at.callable }, + ...{ guards: at.guards ?? [], rule: at.rule ?? null }, + selectors: at.selectors ?? null, + // Read inside a `font` shorthand: values are shorthands too. + shorthand: at.shorthand ?? false, + }; + return [ + ...vars.map(({ name, fallback }) => ({ + ...{ name, namespace: null, mode: 'family', fallback }, + ...place, + })), + // A string is a name, unless Sass interpolates in it (`"#{$x}"`). + ...[ + ...outside + .replace(/(['"])(?:\\[\s\S]|(?!\1)[^\\])*\1/g, (m) => + m.includes('#{') ? m : ' '.repeat(m.length) + ) + .matchAll(REFERENCE), + ].map((match) => ({ + name: identity(match[1] ?? match[3]), + namespace: match[2] ?? null, + ...{ mode: 'family', fallback: null }, + ...place, + })), + ]; +} + +/** + * The names a Sass property name can compose to: a quoted or bare literal + * interpolation stands for itself and a variable for each value + * `valuesOf` gives it (none when it cannot be resolved), up to 16. + */ +function composedNames(name, valuesOf) { + let names = ['']; + let last = 0; + for (const match of name.matchAll(/#\{\s*([^{}]*?)\s*\}/g)) { + const inner = match[1]; + const literal = /^(['"])(.*)\1$/.exec(inner); + let values = []; + if (literal) values = [literal[2]]; + else if (/^[\w-]+$/.test(inner)) values = [inner]; + else if (/^\$[\w-]+$/.test(inner)) values = valuesOf(inner); + const between = name.slice(last, match.index); + names = names + .flatMap((prefix) => values.map((v) => prefix + between + v)) + .slice(0, 16); + last = match.index + match[0].length; + } + return names.map((prefix) => prefix + name.slice(last)); +} + +/** + * Where the parameters end before a TypeScript return type that `before` + * ends with (`): { weight: number }`, `): Promise<{ a: 1 }>`), or -1: the + * return type is read back as one balanced expression to its `:`. + */ +function returnTypeStart(before) { + let depth = 0; + for (let k = before.length - 1; k >= 0; k -= 1) { + const char = before[k]; + if (char === '>' && before[k - 1] === '=') { + k -= 1; + } else if ('})]>'.includes(char)) { + depth += 1; + } else if ('{([<'.includes(char)) { + if (depth === 0) return -1; + depth -= 1; + } else if (depth === 0 && char === ':') { + return before.slice(0, k).trimEnd().length; + } else if (depth === 0 && /[;=]/.test(char)) { + return -1; + } + } + return -1; +} + +/** + * Whether the `{` at `brace` opens a function body: after `=>`, or after a + * parameter list that no `if`/`for`/`while`/`switch`/`catch` owns. + */ +function opensFunction(text, brace) { + let before = text.slice(0, brace).trimEnd(); + if (before.endsWith('=>')) return true; + // A TypeScript return type sits between the parameters and the body. + const typed = returnTypeStart(before); + if (typed !== -1) before = before.slice(0, typed); + if (!before.endsWith(')')) return false; + let depth = 0; + let k = before.length - 1; + for (; k >= 0; k -= 1) { + if (before[k] === ')') depth += 1; + else if (before[k] === '(' && (depth -= 1) === 0) break; + } + const word = /([\w$]+)\s*$/.exec(before.slice(0, k))?.[1] ?? ''; + return !/^(?:if|for|while|switch|catch|with)$/.test(word); +} + +/** Whether `index` sits where a definition exists: a loop variable's body. */ +function inLoopOf(definition, index) { + const { loop } = definition; + return !loop || (loop.start < index && index < loop.end); +} + +/** + * Whether the family in effect in a block of CSS text is JetBrains Mono: the + * last `font-family` or parsing `font` declaration, unless an earlier one is + * `!important`. + */ +function familyInCss(css) { + let effective = null; + for (const [, property, value] of css.matchAll(FAMILY_DECLARATION)) { + if (property.toLowerCase() === 'font' && !parsesAsFont(value)) continue; + const important = IMPORTANT.test(value); + if (effective?.important && !important) continue; + const shorthand = property.toLowerCase() === 'font'; + effective = { important, mono: rendersMono(value, { shorthand }) }; + } + return effective?.mono ?? false; +} + +/** + * A `@for` variable read as a weight: a range of literal bounds is one + * weight only when both are the same scale weight; any other range (or a + * bound Sass computes) yields values the source does not show. + */ +function rangeAnalysis({ range, value }) { + const [from, to] = [Number(range.from), Number(range.to)]; + const literal = Number.isFinite(from) && Number.isFinite(to); + // `through` includes the end, `to` stops before it (`from 6 to 6` runs + // no times). + const count = Math.abs(to - from) + (range.through ? 1 : 0); + const silent = + literal && + (count === 0 || (count === 1 && WEIGHT_SCALE.includes(from))); + return { + terms: silent ? [] : [{ value, computed: true }], + references: [], + }; +} + +/** + * The positions in a Sass list where `char` sits outside parentheses and + * strings (`("a, b" 650, c)` has one top-level comma). + */ +function topLevel(text, char) { + const at = []; + let depth = 0; + let quote = ''; + for (let i = 0; i < text.length; i += 1) { + if (quote) { + if (text[i] === '\\') i += 1; + else if (text[i] === quote) quote = ''; + } else if (text[i] === '"' || text[i] === "'") quote = text[i]; + else if (text[i] === '(') depth += 1; + else if (text[i] === ')') depth -= 1; + else if (text[i] === char && depth === 0) at.push(i); + } + return at; +} + +/** Where a list item's `:` sits outside parentheses and strings, or -1. */ +function topLevelColon(item) { + return topLevel(item, ':')[0] ?? -1; +} + +/** A Sass list split at its top-level commas, outer parentheses dropped. */ +function listItems(list) { + const inner = /^\((.*)\)$/s.exec(list.trim())?.[1] ?? list; + const items = []; + let from = 0; + for (const comma of topLevel(inner, ',')) { + items.push(inner.slice(from, comma).trim()); + from = comma + 1; + } + return [...items, inner.slice(from).trim()].filter(Boolean); +} + +/** + * What each variable of `@each $a, $b in …` takes, as a list: for a map, + * the first the keys and the second the values; for a list of lists, each + * its position in every item (a quoted `"wide label"` is one element). One + * variable takes the whole list. + */ +function loopColumns(list, count) { + if (count === 1) return [list]; + const items = listItems(list); + // A map entry has its `:` outside parentheses (`(a b): 650` too). + const colons = items.map(topLevelColon); + const map = colons.every((colon) => colon !== -1); + const rows = items.map((item, i) => + map + ? [item.slice(0, colons[i]), item.slice(colons[i] + 1)] + : tokensOf(item.replace(/^\((.*)\)$/s, '$1')) + ); + return Array.from({ length: count }, (_, i) => + rows + .map((row) => (row[i] ?? '').trim()) + .filter(Boolean) + .join(', ') + ); +} + +/** + * A term that only the JetBrains Mono cap makes a finding: a scale weight + * (or `bold`) above it. An off-scale one is reported as such already. + */ +function capOnly(term) { + const offScale = + /^[+\d.]/.test(term.value) && + !WEIGHT_SCALE.includes(Number(term.value)); + return Boolean(term.cap) && !offScale; +} + +/** A string literal in code, whole: its quote and its text. */ +const STRING_LITERAL = /^\s*(['"`])([\s\S]*)\1\s*$/; + +/** + * The CSS text a value set from code can hold: the text of each string + * literal it can give (see `resultsOf`); any other result as written. + */ +function cssOfCode(expression) { + return resultsOf(expression).map( + (result) => STRING_LITERAL.exec(result)?.[2] ?? result + ); +} + +/** A JavaScript number literal (`0x28a`, `0o1212`, `6_50`, `650n`). */ +const JS_NUMBER = + /(? { + if (!/^0[xob]|_|n$/i.test(literal)) return literal; + const value = Number(literal.replace(/_|n$/gi, '')); + return Number.isFinite(value) ? String(value) : literal; + }); +} + +/** A literal's text as JavaScript puts it in a string, or `null`. */ +function literalText(result) { + const string = STRING_LITERAL.exec(result); + if (string) return string[2].includes('${') ? null : string[2]; + const number = Number(result.trim()); + return Number.isFinite(number) ? String(number) : null; +} + +/** + * The texts a template literal's body can produce when every `${…}` in it + * gives literals (`65${0}` is `650`, `${wide ? 650 : 600}` is `650` or + * `600`), up to 16 of them; `null` when one gives anything else. + */ +export function templateTexts(body) { + let texts = ['']; + for (const part of templateParts(body)) { + const values = + part.code === undefined + ? [part.text] + : resultsOf(part.code).map(literalText); + if (values.includes(null)) return null; + texts = texts.flatMap((text) => values.map((value) => text + value)); + if (texts.length > 16) return null; + } + return texts; +} + +/** Analyses merged into one. */ +function merged(analyses) { + return { + terms: analyses.flatMap((analysis) => analysis.terms), + references: analyses.flatMap((analysis) => analysis.references), + }; +} + +/** + * A value set from code, read per result it can take (see `resultsOf`), so + * a condition's numbers are not weights. A string literal is CSS text and is + * read as such, a template as each text it can produce; anything else is an + * expression, where other numbers appear too (a weight is 100 or more) and + * arithmetic computes the value. + */ +function analyseCode(expression, mode = 'weight', after = 0, cap = null) { + // A shorthand that names JetBrains Mono meets its cap. + const css = (text) => { + const mono = mode === 'font' && rendersMono(text, { shorthand: true }); + return analyse(mode, text, { + after, + cap: mono ? MONO_WEIGHT_CAP : cap, + }); + }; + return merged( + resultsOf(expression).map((result) => { + const literal = STRING_LITERAL.exec(result); + if (!literal) { + return analyse('weight', numberText(result), { + minimum: 100, + code: true, + cap, + }); + } + const texts = literal[2].includes('${') + ? templateTexts(literal[2]) + : [literal[2]]; + if (texts) return merged(texts.map(css)); + // `${w}` alone is `w`. Around a value the scan cannot know, a + // shorthand's other tokens still read as CSS, while a weight is + // computed from it (`6${w}`). + const parts = templateParts(literal[2]); + if (parts.length === 1 && parts[0].code !== undefined) { + return analyseCode(parts[0].code, mode, after, cap); + } + if (mode === 'font') return css(literal[2]); + return { + terms: [{ value: result.trim(), computed: true }], + references: [], + }; + }) + ); +} + +/** + * Where each mixin or function a stylesheet defines is called in it + * (`@include name`, `name(`), skipping the definition itself, with the + * callable a call sits in (`within`), if any: such a call runs only when + * that callable does. + */ +function callSitesOf(text, blocks) { + const calls = {}; + const names = blocks + .filter((block) => block.kind === 'callable' && block.name) + .map((block) => block.name); + for (const name of new Set(names)) { + // Sass reads `-` and `_` alike, so `heading_style` calls `heading-style`. + const escaped = name + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/-/g, '[-_]'); + const call = new RegExp( + String.raw`@include\s+${escaped}(?![\w-])|(? !namesCallable(text, match.index)) + .map(({ index }) => ({ + index, + within: placeOf(blocks, index).callable, + })); + } + return calls; +} + +/** + * One file's weight declarations: off-scale findings, the custom properties + * and Sass variables its weights refer to, and every such variable it defines + * (checked later, once the whole workspace has named what it refers to). + * A keyframe that sets a font only while it runs is read both ways: its + * frames over the rule that runs it, and that rule after it. + */ +export function scanWeights(file, written) { + const running = scanPass(file, written, true); + if (!running.transient) return running; + const after = scanPass(file, written, false); + const keyOf = (item) => JSON.stringify(item); + const union = (a, b) => [ + ...new Map([...a, ...b].map((item) => [keyOf(item), item])).values(), + ]; + return { + ...running, + findings: union(running.findings, after.findings), + deferred: union(running.deferred, after.deferred), + }; +} + +function scanPass(file, written, transient) { + // Read as the browser reads it (markup references and CSS escapes + // decoded); lines are the file's own. + const { text: source, origin } = decodeSource(file, written); + const lexed = lex(file, source); + const { text, quoteAt } = lexed; + const writtenLine = lineIndex(written); + const lineOf = origin ? (index) => writtenLine(origin[index]) : writtenLine; + const stylesheet = STYLESHEET.test(file); + const patterns = stylesheet + ? [STYLESHEET_WEIGHT, FONT_SHORTHAND] + : [SOURCE_WEIGHT, FONT_SHORTHAND]; + // In a stylesheet a string is content (`content: "…"`), never a rule. + const inString = (index) => stylesheet && quoteAt[index] !== ''; + const findings = []; + const references = []; + const definitions = []; + let declarations = 0; + const blocks = stylesheet ? blocksOf(lexed) : []; + const blockAt = new Map(blocks.map((block) => [block.start, block])); + // The flow-control and conditional at-rule blocks around a position. + const guardsAt = (index) => + blocks + .filter((block) => block.start < index && index < block.end) + .filter( + (block) => + block.kind === 'flow' || + CONDITIONAL_RULE.test(block.prelude) + ) + .map((block) => block.start); + const record = (name, index, analysis) => { + declarations += 1; + for (const term of analysis.terms) { + findings.push({ file, line: lineOf(index), name, ...term }); + } + const { scopes, inCallable, callable } = placeOf(blocks, index); + references.push( + ...analysis.references.map((reference) => ({ + ...{ ...reference, file, index }, + ...{ scopes, inCallable, callable }, + })) + ); + }; + + // The character before `index`, whitespace skipped. + const charBefore = (index) => { + let before = index - 1; + while (before >= 0 && /\s/.test(text[before])) before -= 1; + return text[before] ?? ''; + }; + // This file's Sass variable declarations, read for interpolated + // selectors (see `selectorOf`), in source order. + let assignments = null; + const assignmentsOf = () => + (assignments ??= [...text.matchAll(DEFINITION)] + .filter( + ({ 1: name, index }) => name.startsWith('$') && !inString(index) + ) + // A `$x: 1` right after `(` or `,` is an argument. + .filter(({ index }) => !['(', ','].includes(charBefore(index))) + .map((match) => ({ + key: identity(match[1]), + index: match.index, + value: valueAfter(lexed, match.index + match[0].length).value, + place: placeOf(blocks, match.index), + }))); + // The literal a Sass variable holds at `at`: its last declaration there, + // when that is a plain name or string set unconditionally in a scope + // around `at`; `null` when the scan cannot know (a module member, an + // `@if`, a `!default` / `!global` assignment, or one outside the mixin + // that `at` is in). + const literalAt = (name, at) => { + const key = identity(name); + const scopes = placeOf(blocks, at).scopes; + const before = assignmentsOf().filter( + (a) => a.key === key && a.index < at + ); + if (before.some((a) => /!\s*(?:default|global)\b/i.test(a.value))) { + return null; + } + const last = before + .filter((a) => scopes.includes(a.place.scope)) + .at(-1); + if (!last || last.place.flow) return null; + // In a mixin, only its own assignments are known where it is + // included; one outside may change before the `@include`. + const inside = placeOf(blocks, at); + if (inside.inCallable && last.place.callable !== inside.callable) { + return null; + } + const value = /^\s*(?:([\w-]+)|(['"])([^'"]*)\2)\s*$/.exec(last.value); + return value ? (value[1] ?? value[3]) : null; + }; + // The selector chains of the blocks around a place, innermost first: + // one per selector of each list (`.a, .b { .x {} }` is `.x < .a` and + // `.x < .b`), and two rules with a chain in common style its elements. + // Spacing outside strings is the same selector; inside one + // (`[title="a b"]`) it is not. A Sass interpolation reads the literal + // its variable holds there (`$n: a; .#{$n}` is `.a`); one the scan cannot + // know, or more than 16 chains, makes the block's chain its own. + const selectorsOf = (scopes) => { + let chains = ['']; + // Past an `@at-root`, the blocks it leaves out (see + // `atRootExcludes`) are not written out around it. + let excluded = () => false; + for (const scope of scopes) { + if (scope === null) continue; + const written = blockAt + .get(scope) + .prelude.replace(SPACING, (m) => (/^\s/.test(m) ? ' ' : m)) + .replace( + /#\{\s*(\$[\w-]+)\s*\}/g, + (m, name) => literalAt(name, scope) ?? m + ); + const excludes = atRootExcludes(written); + const prelude = excludes + ? written.replace(/^@at-root\b\s*/i, '') + : written; + if (excludes) { + const outer = excluded; + excluded = (kind) => outer(kind) || excludes(kind); + if (!prelude || prelude.startsWith('(')) continue; + } else if (excluded(blockKind(prelude))) continue; + // Each unnamed `@layer { … }` is a layer of its own. + const parts = /^@layer\s*$/i.test(prelude) + ? [`@layer @${scope}`] + : /^@|#\{/.test(prelude) + ? [prelude] + : selectorList(prelude).map(canonicalSelector); + chains = chains.flatMap((chain) => + parts.map((part) => (chain ? `${chain} < ${part}` : part)) + ); + } + const known = chains.length <= 16 && !chains.join().includes('#{'); + return known ? chains : [`${chains.join(', ')} @ ${scopes.join(' ')}`]; + }; + // Blocks with a selector chain in common (and the same `@if` or `@each` + // around them) are one rule to the cascade for it: its declarations + // apply in source order, whichever block holds them. A block is in one + // rule per chain. + const rulesOf = (start) => { + if (start === null || start === undefined) return []; + const flow = blocks + .filter( + (b) => b.kind === 'flow' && b.start < start && start < b.end + ) + .map((b) => b.start); + const chains = selectorsOf(placeOf(blocks, start + 1).scopes); + return chains.map((chain) => `${chain} | ${flow.join(' ')}`); + }; + // The family each rule renders in (see `familiesOf`); one named through + // variables is resolved once the whole workspace is scanned. + const refsIn = (parts, index, place) => + familyRefs(parts, { + ...{ file, index, ...place, guards: guardsAt(index) }, + rule: blockAt.get(place.scope)?.prelude ?? null, + selectors: selectorsOf(place.scopes), + }); + const monoAt = stylesheet + ? familiesOf(lexed, blocks, { + ...{ inString, placeOf, refsIn, rulesOf, transient }, + }) + : Object.assign(() => ({ mono: false, refs: [] }), { + landings: () => [], + }); + // Weights in rules whose family is named through variables: capped once + // that family resolves to JetBrains Mono. + const deferred = []; + // Each rule's weight declarations (`font-weight` and the `font` + // shorthand): a later one, unless only the earlier is `!important`, + // replaces it, so only the one in effect meets the cap. + const setters = new Map(); + // A weight declaration registers in each rule it lands in (a mixin's + // where it is included), at the place it lands. + const setAt = (index, important) => { + // A keyframe's weight, where a rule runs it, outranks the rule's own. + for (const { rules, key, animated } of monoAt.landings(index)) { + for (const rule of rules) { + if (!setters.has(rule)) setters.set(rule, []); + const level = levelOf({ important, animated }); + setters.get(rule).push({ key, index, level }); + } + } + }; + for (const match of stylesheet ? text.matchAll(WEIGHT_SETTER) : []) { + if (inString(match.index) || inConditionPrelude(lexed, match.index)) { + continue; + } + if (!startsDeclaration(text, match.index)) continue; + const start = match.index + match[0].length; + const { value, selector } = declarationText(lexed, start); + if (selector) continue; + if (match[1].toLowerCase() === 'font' && !parsesAsFont(value)) continue; + // A nested `font: { weight: … }` sets its rule's weight. + const namespace = fontNamespaceRule( + blocks, + placeOf(blocks, match.index) + ); + if (match[1].toLowerCase() === 'weight' && namespace === undefined) { + continue; + } + setAt(match.index, IMPORTANT.test(value)); + } + // An `all` reset replaces an earlier weight too. + for (const match of stylesheet ? text.matchAll(ALL_RESET) : []) { + if (inString(match.index) || inConditionPrelude(lexed, match.index)) { + continue; + } + if (startsDeclaration(text, match.index)) + setAt(match.index, Boolean(match[2])); + } + + // A shorthand that fails to parse is dropped, so it sets nothing. + // In a selector list, it is in effect while it is for any selector. + // A weight is in effect where, in a rule it lands in, nothing later + // replaces it and nothing earlier outranks it (`!important`): the + // scopes of those landings, the only ones whose family it meets. + const after = (a, b) => keyOrder(a.key, b.key) > 0; + const effectiveIn = (index) => { + const landings = monoAt.landings(index).filter(({ rules, key }) => + rules.some((id) => { + const rule = setters.get(id) ?? []; + const own = rule.find( + (setter) => + setter.index === index && + keyOrder(setter.key, key) === 0 + ); + return ( + Boolean(own) && + !rule.some( + (other) => + other !== own && + (other.level > own.level || + (other.level === own.level && + after(other, own))) + ) + ); + }) + ); + return new Set(landings.map(({ scope }) => scope)); + }; + + // CSS text in a string (an inline `style="…"`, a component style) meets + // the Mono cap when the declarations around it set JetBrains Mono. + const monoInString = (index) => { + const quote = quoteAt[index]; + if (!quote) return false; + let start = index; + let end = index; + while (start > 0 && quoteAt[start - 1] === quote) start -= 1; + while (end < text.length && quoteAt[end] === quote) end += 1; + // The closing quote is not CSS. + if (text[end - 1] === quote) end -= 1; + const before = text.slice(start, index); + const after = text.slice(index, end); + const rule = + before.slice( + Math.max(before.lastIndexOf('{'), before.lastIndexOf('}')) + 1 + ) + after.slice(0, after.search(/[{}]|$/)); + return familyInCss(rule); + }; + // Whether a `return` at `at` belongs to a function nested in the body + // opened at `open` (an arrow, `function` or method), not to the body. + const inNestedFunction = (open, at) => { + const braces = []; + for (let i = open + 1; i < at; i += 1) { + if (quoteAt[i]) continue; + if (text[i] === '{') braces.push(i); + else if (text[i] === '}') braces.pop(); + } + return braces.some((brace) => opensFunction(text, brace)); + }; + // A feature query's test is a condition, not a declaration. + const inPrelude = (index) => stylesheet && inConditionPrelude(lexed, index); + // In markup only CSS contexts style anything (see `inMarkupCss`). + const markup = /\.(?:html|svg)$/.test(file); + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + if (inString(match.index) || inPrelude(match.index)) continue; + if (markup && !inMarkupCss(lexed, match.index)) continue; + const name = match[1]; + const end = match.index + match[0].length; + const read = valueAfter(lexed, end); + const { selector } = read; + // An unquoted attribute value ends at a space or `>`. + const unquoted = + markup && + !quoteAt[match.index] && + inUnquotedStyle(lexed, match.index); + const value = unquoted ? read.value.split(/[\s>]/)[0] : read.value; + if (selector) continue; + const mode = name.toLowerCase() === 'font' ? 'font' : 'weight'; + // A TypeScript object value (`{ fontWeight: wide ? 700 : 600 }`), + // or one in an Angular binding (`[ngStyle]="{…}"`), is code, read + // per value it can take; a string is CSS text. + const code = + (file.endsWith('.ts') && !quoteAt[end]) || inBinding(text, end); + if (!stylesheet && code) { + const expression = codeExpression(text, end, { + argument: true, + }); + record(name, match.index, analyseCode(expression, mode)); + continue; + } + const nested = + /^weight$/i.test(name) && + fontNamespaceRule(blocks, placeOf(blocks, match.index)) !== + undefined; + const weightName = /^font(?:-weight)?$/i.test(name) || nested; + const effective = weightName ? effectiveIn(match.index) : new Set(); + const family = + effective.size > 0 + ? monoAt(match.index, (scope) => effective.has(scope)) + : { mono: false, refs: [] }; + const inline = + !stylesheet && weightName && monoInString(match.index); + const cap = family.mono || inline ? MONO_WEIGHT_CAP : null; + // CSS text in a string of code ends with that string; a value + // left to code (`'font-weight:' + 650`) is the operand after `+`. + const literal = stylesheet ? null : codeStringAt(lexed, file, end); + const css = literal ? value.slice(0, literal.close - end) : value; + const operand = + literal && /^\s*$/.test(css) + ? concatenatedAfter(text, literal) + : null; + if (operand !== null) { + record( + name, + match.index, + analyseCode(operand, 'weight', 0, cap) + ); + continue; + } + // A template's CSS reads as each text it can produce. + const template = !stylesheet && css.includes('${'); + const texts = template ? templateTexts(css) : null; + const cssOf = (text) => analyse(mode, text, { cap }); + record(name, match.index, merged((texts ?? [css]).map(cssOf))); + if (!family.mono && family.refs.length > 0) { + const capped = analyse(mode, value, { cap: MONO_WEIGHT_CAP }); + const place = placeOf(blocks, match.index); + deferred.push({ + ...{ file, line: lineOf(match.index), name }, + family: family.text, + shorthand: family.shorthand, + at: { + ...{ ...family.at, file }, + guards: guardsAt(family.at.index), + rule: blockAt.get(family.at.scope)?.prelude ?? null, + selectors: selectorsOf(family.at.scopes ?? []), + }, + terms: capped.terms.filter(capOnly), + references: capped.references.map((reference) => ({ + ...{ ...reference, file, index: match.index }, + ...{ scopes: place.scopes, callable: place.callable }, + inCallable: place.inCallable, + })), + }); + } + } + } + // A weight set from code is checked here; any other custom property it + // sets is a definition that a stylesheet's `var()` may refer to. + const setByCode = (name, property, index, expression) => { + if (/^font-?weight$|^--.*weight$/i.test(property)) { + record(name, index, analyseCode(expression)); + } else if (/^font$/i.test(property)) { + record(name, index, analyseCode(expression, 'font')); + } else if (property.startsWith('--')) { + const line = lineOf(index); + const value = expression; + const key = property; + definitions.push({ + file, + line, + name: property, + key, + value, + code: true, + }); + } + }; + if (!stylesheet) { + for (const match of text.matchAll(ATTRIBUTE_WEIGHT)) { + // An attribute sits inside a tag; text such as + // `

font-weight=750

` sets nothing. + const html = /\.(?:html|svg)$/.test(file); + if (!insideTag(lexed, match.index, { html })) continue; + const value = match[3] ?? match[4]; + record(match[1], match.index, analyse('weight', value)); + } + for (const match of text.matchAll(CODE_BINDING)) { + setByCode(match[1], match[2], match.index, match[4]); + } + for (const match of text.matchAll(HOST_BINDING)) { + const [label, , target, property] = match; + if (target === 'attr' && property !== 'font-weight') continue; + const end = match.index + label.length; + // A getter's value is whatever any `return` in its body gives. + const getter = HOST_GETTER.exec(text.slice(end)); + if (getter) { + const open = end + getter[0].length - 1; + const body = text.slice(open, closingBrace(lexed, open)); + for (const statement of body.matchAll(/\breturn\b/g)) { + const at = open + statement.index; + if (quoteAt[at] || inNestedFunction(open, at)) continue; + const expression = codeExpression(text, at + 6); + setByCode(label, property, match.index, expression); + } + continue; + } + const field = HOST_FIELD.exec(text.slice(end)); + if (!field) continue; + const expression = codeExpression(text, end + field[0].length); + setByCode(label, property, match.index, expression); + } + for (const match of text.matchAll(CODE_ASSIGNMENT)) { + const end = match.index + match[0].length; + const expression = codeExpression(text, end).trim(); + const operator = match[5]; + const shorthand = (match[2] ?? match[4]) === 'font'; + if (operator && !LOGICAL_ASSIGNMENT.test(operator)) { + const value = `${operator}= ${expression}`; + const terms = [{ value, computed: true }]; + record(match[1], match.index, { terms, references: [] }); + } else if (shorthand) { + record(match[1], match.index, analyseCode(expression, 'font')); + } else { + setByCode(match[1], 'font-weight', match.index, expression); + } + } + for (const match of text.matchAll(CODE_SETTER)) { + const { skip, property } = SETTERS[match[1]]; + let at = match.index + match[0].length; + for (let k = 0; k < skip && at < text.length; k += 1) { + at += codeExpression(text, at, { argument: true }).length + 1; + } + const quoted = QUOTED_NAME.exec(text.slice(at)); + if (!quoted || !property.test(quoted[3])) continue; + const value = at + quoted[0].length; + const expression = codeExpression(text, value, { argument: true }); + const name = text + .slice(match.index, at + quoted[1].length) + .replace(/\s+/g, ''); + setByCode(name, quoted[3], match.index, expression); + } + } + for (const match of text.matchAll(DEFINITION)) { + const name = match[1]; + if (inString(match.index) || inPrelude(match.index)) continue; + if (!stylesheet && name.startsWith('$')) continue; + const end = match.index + match[0].length; + const { value, selector } = valueAfter(lexed, end); + if (selector) continue; + // `$x: 1` right after `(` or `,` is an argument (a mixin call, a + // `with (…)` configuration); otherwise it declares the variable. + const argument = ['(', ','].includes(charBefore(match.index)); + const line = lineOf(match.index); + const key = identity(name); + const index = match.index; + // A declaration belongs to its innermost scope (see `placeOf`); a + // `!global` one assigns the module variable, whenever it runs. + const place = placeOf(blocks, index); + const global = /!global\b/i.test(value); + // `!default` assigns only while the variable is unset (see + // `effectiveDeclarations`). + const fallback = /!default\b/i.test(value); + const full = argument ? value : declarationText(lexed, end).value; + definitions.push({ + ...{ file, line, index, name, key, value, argument, fallback }, + // The whole declaration, for a family list (`a, b`). + full, + important: IMPORTANT.test(full), + // The selector of the rule it sits in, for custom properties. + rule: blockAt.get(place.scope)?.prelude ?? null, + selectors: selectorsOf(place.scopes), + // The rules it belongs to in the cascade (see `rulesOf`). + cascades: rulesOf(place.scope), + guards: guardsAt(index), + // Checked against the scale where it is declared (see below). + weighted: /weight$/i.test(name), + // An argument reaches only the mixin or function it is passed to. + callee: argument ? calleeOf(text, index) : null, + scope: global ? null : place.scope, + // A `!global` assignment runs in text order, unless flow + // control or a callable body (run when called) encloses it. + conditional: global + ? place.flow || place.inCallable + : place.conditional, + ...{ scopes: place.scopes, inCallable: place.inCallable }, + callable: place.callable, + }); + } + + // `@property --x { initial-value: … }` gives a registered property its + // value wherever nothing sets it; a `…weight` name is checked there. + for (const match of stylesheet ? text.matchAll(PROPERTY_RULE) : []) { + if (inString(match.index)) continue; + const brace = match.index + match[0].length - 1; + const body = text.slice(brace, blockAt.get(brace)?.end ?? brace); + const initial = /(? { + const index = header.index; + const brace = index + header[0].length - 1; + const body = blockAt.get(brace); + const place = placeOf(blocks, index); + definitions.push({ + ...{ file, line: lineOf(index), index, name, key: identity(name) }, + ...{ value, full: value, argument: false, fallback: false }, + ...{ important: false, weighted: false, callee: null, range }, + rule: blockAt.get(place.scope)?.prelude ?? null, + ...{ guards: guardsAt(index), scope: place.scope }, + ...{ conditional: false, scopes: place.scopes }, + ...{ inCallable: place.inCallable, callable: place.callable }, + loop: { start: brace, end: body?.end ?? brace }, + }); + }; + for (const match of stylesheet ? text.matchAll(EACH_LOOP) : []) { + if (inString(match.index)) continue; + const names = match[1].split(',').map((name) => name.trim()); + const columns = loopColumns(match[2].trim(), names.length); + names.forEach((name, i) => loopVariable(name, match, columns[i])); + } + for (const match of stylesheet ? text.matchAll(FOR_LOOP) : []) { + if (inString(match.index)) continue; + const [, name, from, bound, to] = match; + const value = `from ${from.trim()} ${bound} ${to.trim()}`; + const range = { + from: from.trim(), + to: to.trim(), + through: bound === 'through', + }; + loopVariable(name, match, value, range); + } + + // Sass can build a property name; one that composes to a weight name + // (from literals, or this file's variables as they stand there) is + // checked like one. + const valuesAt = (variable, index) => { + const place = placeOf(blocks, index); + const key = identity(variable); + const candidates = definitions.filter( + (d) => d.key === key && !d.argument && inLoopOf(d, index) + ); + // A loop variable takes each item of its list in turn. + return effectiveDeclarations( + { index, ...place }, + candidates + ).picked.flatMap((d) => + d.loop + ? d.value + .replace(/^\((.*)\)$/s, '$1') + .split(',') + .map(sassValue) + : [sassValue(d.value)] + ); + }; + // A name whose own tail ends in `weight` is checked as written above. + for (const match of stylesheet ? text.matchAll(INTERPOLATED_NAME) : []) { + if (inString(match.index) || inPrelude(match.index)) continue; + if (/weight$/i.test(match[1])) continue; + // A name that can compose to several (`font` or `font-weight`) is + // read every way it can. + const modes = new Set( + composedNames(match[1], (variable) => + valuesAt(variable, match.index) + ) + .filter((candidate) => WEIGHT_NAME.test(candidate)) + .map((candidate) => + candidate.toLowerCase() === 'font' ? 'font' : 'weight' + ) + ); + if (modes.size === 0) continue; + const end = match.index + match[0].length; + const { value, selector } = valueAfter(lexed, end); + if (selector) continue; + const analyses = [...modes].map((mode) => analyse(mode, value)); + const terms = new Map( + analyses + .flatMap((analysis) => analysis.terms) + .map((term) => [JSON.stringify(term), term]) + ); + record(match[1], match.index, { + terms: [...terms.values()], + references: analyses.flatMap((analysis) => analysis.references), + }); + } + const loads = stylesheet ? extractStylesheetLoads(source) : []; + const calls = callSitesOf(text, blocks); + const invocations = stylesheet ? invocationsOf(lexed) : []; + return { + ...{ file, loads, declarations, findings, references, definitions }, + ...{ calls, invocations, deferred }, + transient: Boolean(monoAt.transient), + }; +} + +/** + * Definitions of the variables that weight declarations refer to, read the + * way they are used (a weight or a whole `font` shorthand) and followed + * through chains (`--a: var(--b)`). A name ending in `weight` is already + * checked against the scale where it is declared, so only a JetBrains Mono + * rule's cap is new there. Custom properties cascade across the app, so + * any definition counts; a Sass variable only what its module scope sees + * (see `sassScopes`). + */ +export function findIndirectWeights(scans) { + const definitions = scans.flatMap((scan) => scan.definitions); + const pending = scans.flatMap((scan) => scan.references); + const { qualified, unqualified, imports, loadsOf } = sassScopes(scans); + const callsByFile = new Map(scans.map((scan) => [scan.file, scan.calls])); + // Whether a declaration is what `name` reads, through one of the ways + // `access` exposes its file (a `@forward` prefix, `show`/`hide`). + const exposes = (access, definition, name) => + Boolean( + access?.exposures.some( + (exposure) => exposedName(exposure, definition.key) === name + ) + ); + // Whether an argument sits in a `with (…)` that `access` counts and sets + // `name` there: the names written in it read through the range's + // exposure, or, for a loader configuring this module, the other way. + const configures = (access, definition, name) => + Boolean( + access?.ranges.some( + ({ start, end, exposure, outward }) => + definition.index >= start && + definition.index < end && + (outward + ? exposedName(exposure, name) === definition.key + : exposedName(exposure, definition.key) === name) + ) + ); + // Whether an argument is passed to `callable`, defined in `file`: + // `ns.name(` must load `file` (or a module forwarding it) as `ns`, under + // the name it exposes `callable` by; a bare `name(` is defined in the + // caller itself or in what it brings in. + const passedTo = ({ callee, file: caller }, file, callable) => { + if (!callee || !callable) return false; + if (!callee.namespace && caller === file) { + return callee.name === callable; + } + const scope = callee.namespace + ? qualified(caller, callee.namespace) + : unqualified(caller); + const access = scope.get(file); + return ( + Boolean(access?.declarations) && + access.exposures.some( + (exposure) => exposedName(exposure, callable) === callee.name + ) + ); + }; + // A parameter default is the value only at calls that leave it out. A + // callable with no call in sight may be called from where the scan + // cannot see, so its defaults count. + const invocations = scans.flatMap(({ file, invocations: calls = [] }) => + calls.map((call) => ({ ...call, file })) + ); + const defaultUsed = new Map(); + const usesDefault = (definition) => { + if (defaultUsed.has(definition)) return defaultUsed.get(definition); + const callable = definition.callee.name; + const calls = invocations.filter( + (call) => + call.callee.name.endsWith(callable) && + passedTo(call, definition.file, callable) + ); + const names = (call) => + definitions.some( + (d) => + d.argument && + d.file === call.file && + d.callee?.paren === call.paren && + d.key === definition.key + ); + const used = calls.length === 0 || calls.some((call) => !names(call)); + defaultUsed.set(definition, used); + return used; + }; + // A partial runs only where it is loaded, so its `!default` for a name + // never applies when every load sets that name (not to `null`), in its + // own `with (…)` or, through a `@forward`, in the forwarding module's + // loads under the prefix. A file that is not a partial may be compiled + // on its own, unconfigured. + const configuredCache = new Map(); + const alwaysConfigured = (file, name, chain = new Set()) => { + const cacheKey = `${file} ${name}`; + if (chain.size === 0 && configuredCache.has(cacheKey)) { + return configuredCache.get(cacheKey); + } + const partial = /^_/.test(path.posix.basename(file)); + if (!partial || chain.has(file)) return false; + const loads = loadsOf(file); + const deeper = new Set([...chain, file]); + const sets = (load) => + definitions.some( + (d) => + d.argument && + d.file === load.file && + d.key === name && + !/^null\b/i.test(d.value.trim()) && + load.ranges.some(([s, e]) => d.index >= s && d.index < e) + ); + const configured = + loads.length > 0 && + loads.every( + (load) => + sets(load) || + (load.forward && + alwaysConfigured( + load.file, + exposedName( + { prefix: load.prefix, filters: [] }, + name + ), + deeper + )) + ); + if (chain.size === 0) configuredCache.set(cacheKey, configured); + return configured; + }; + // `@import` is textual: an imported file's top-level declarations take + // effect where the `@import` sits, transitively. + // Each `@import` runs its file again, so only the current path guards + // against cycles. + // `order` keeps the text order inside the inclusion: the importer's + // position, then each imported file's own positions. + const imported = (file, name, prefix = [], path = new Set([file])) => + imports(file).flatMap(({ loaded, index }) => { + if (path.has(loaded)) return []; + const order = [...prefix, index]; + const position = order[0]; + const own = definitions + .filter((d) => d.file === loaded && d.key === name) + .filter((d) => !d.argument && d.scope === null) + .map((d) => ({ + ...d, + index: position, + order: [...order, d.index], + original: d, + })); + const deeper = new Set([...path, loaded]); + return [...own, ...imported(loaded, name, order, deeper)]; + }); + // Where a callable runs: its call sites, with a call inside another + // callable's body replaced by where that one runs. + const runsAt = (file, name, seen = new Set()) => { + if (!name || seen.has(name)) return []; + seen.add(name); + return (callsByFile.get(file)?.[name] ?? []).flatMap( + ({ index, within }) => + within ? runsAt(file, within, seen) : [index] + ); + }; + // A custom property declared again, unconditionally, later in the same + // rule (any block with its selector, see `rulesOf`) is replaced there, + // unless only the earlier one is `!important`; in a selector list, only + // where that holds for every selector. + const byRule = new Map(); + for (const definition of definitions) { + const { key, argument, cascades, code } = definition; + if (!key.startsWith('--') || argument || code || !cascades?.length) { + continue; + } + // Its rules and selector chains line up (see `rulesOf`). + cascades.forEach((rule, i) => { + const group = `${definition.file} ${rule} ${key}`; + if (!byRule.has(group)) byRule.set(group, []); + byRule + .get(group) + .push({ definition, chain: definition.selectors[i] }); + }); + } + // The selector chains each definition is replaced for. + const overridden = new Map(); + for (const group of byRule.values()) { + for (const { definition, chain } of group) { + const later = group.some( + ({ definition: other }) => + other.index > definition.index && + !other.conditional && + (other.important || !definition.important) + ); + if (!later) continue; + if (!overridden.has(definition)) + overridden.set(definition, new Set()); + overridden.get(definition).add(chain); + } + } + const replacedFor = (definition, chains) => + chains.every((chain) => overridden.get(definition)?.has(chain)); + const replaced = new Set( + [...overridden.keys()].filter((definition) => + replacedFor(definition, definition.selectors) + ) + ); + // The definitions a reference can resolve to, as Sass and the cascade + // read them (see `sassScopes` and `effectiveDeclarations`). + const visibleDefinitions = (reference) => { + const { name, file, namespace } = reference; + const sass = name.startsWith('$'); + const members = sass && namespace ? qualified(file, namespace) : null; + const scope = sass && !namespace ? unqualified(file) : null; + // A `with (…)` of this very lookup sets the name, so the module's + // `!default` for it never applies here. + // `null` counts as unset, so the `!default` still applies. + const configuredHere = definitions.some( + (d) => + d.argument && + d.file === file && + !/^null\b/i.test(d.value.trim()) && + configures((members ?? scope)?.get(file), d, name) + ); + // In its own file, only the declarations in effect at the reference + // count; other files' declarations only when those settle nothing. + const own = scope + ? effectiveDeclarations( + reference, + [ + ...definitions.filter( + (d) => + d.key === name && + d.file === file && + !d.argument && + inLoopOf(d, reference.index) + ), + ...imported(file, name), + ], + runsAt(file, reference.callable) + ) + : null; + const picked = new Set(own?.picked.map((d) => d.original ?? d)); + return definitions.filter((definition) => { + const access = (members ?? scope)?.get(definition.file); + if (!sass && definition.key !== name) return false; + // A loop variable exists only inside its loop, in its file. + if (definition.loop) { + const here = definition.file === file; + if (!here || !inLoopOf(definition, reference.index)) + return false; + } + if (!sass && replaced.has(definition)) return false; + // An argument's name is matched where it is passed, below. + if (sass && !definition.argument) { + if (!exposes(access, definition, name)) return false; + } + if ( + configuredHere && + definition.fallback && + !definition.argument && + definition.file !== file + ) { + return false; + } + if ( + sass && + definition.fallback && + !definition.argument && + definition.scope === null && + alwaysConfigured(definition.file, definition.key) + ) { + return false; + } + if (sass && !definition.argument && !picked.has(definition)) { + if (definition.file === file && !members) return false; + // Other modules see only top-level (or `!global`) members. + if (definition.scope !== null || own?.settled) return false; + } + if (members) { + const visible = definition.argument + ? configures(access, definition, name) + : access?.declarations; + if (!visible) return false; + } + if (scope) { + const passed = + definition.key === name && + passedTo(definition, file, reference.callable) && + (!definition.callee.signature || usesDefault(definition)); + const configured = configures(access, definition, name); + // A textual importer's later code has not run when this + // file's rules render, unless they sit in a mixin body. + const ran = + reference.inCallable || + definition.index < (access?.before ?? Infinity); + const visible = definition.argument + ? access?.arguments && (passed || configured) + : access?.declarations && ran; + if (!visible) return false; + } + return true; + }); + }; + // Whether a definition sets a custom property for every element the + // reading rule styles: one in a rule every element inherits from + // (`:root`), or in the reading rule itself, one with the same selector, + // or a rule it is nested in (or its component's `:host`). A property set by code sits in another file + // and is set on some element only. + const setsFor = (definition, reference) => { + // A registered property always has a value: its `initial-value`. + if (definition.registered) return true; + const own = definition.file === reference.file; + // A condition (`@media`, `@if`, …) around the definition that does + // not also hold around the reading declaration may leave it unset. + const shared = own ? (reference.guards ?? []) : []; + if ((definition.guards ?? []).some((g) => !shared.includes(g))) { + return false; + } + if (own && (reference.scopes ?? []).includes(definition.scope)) { + return true; + } + // The same selector written again in the file styles the same + // elements (another file's component styles reach other ones). + if (own && covers(definition.selectors, reference.selectors)) { + return true; + } + if (reachesEverything(definition.rule, reference.rule)) return true; + return own && plainHost(definition.rule); + }; + // Whether a custom property can be unset where it is read, so a `var()` + // fallback applies: no rule sets it (one that inherits sets nothing of + // its own), one resets it, or one sets it to a value that can be invalid. + // A property that reads itself, directly or through others + // (`--face: var(--face)`), is in a cycle, which CSS makes invalid. + const mayBeUnset = (reference, path = new Set()) => { + const key = `${reference.file} ${reference.index} ${reference.name}`; + if (path.has(key)) return true; + path.add(key); + const own = visibleDefinitions(reference) + .map((definition) => ({ + definition, + text: (definition.full ?? definition.value).trim(), + })) + .filter(({ text }) => !INHERITING.test(text)); + const unset = + !own.some(({ definition }) => setsFor(definition, reference)) || + own.some( + ({ definition, text }) => + RESETTING.test(text) || mayBeInvalid(text, definition, path) + ); + path.delete(key); + return unset; + }; + // A value is invalid when a `var()` in it reads a property that can be + // unset and its own fallback (if any) can be invalid too. + const mayBeInvalid = (text, at, path) => + familyRefs(familyParts(text), at).some( + (ref) => + mayBeUnset(ref, path) && + (ref.fallback === null || mayBeInvalid(ref.fallback, at, path)) + ); + // How a family list (or one variable's lists) decides JetBrains Mono, + // entry by entry in its order: `mono`, `stop` (a family that renders + // every glyph comes first) or `open`. A variable stands for each list + // it can hold (its `var()` fallback too where it can be unset); any + // branch that renders Mono counts. + const combine = (verdicts) => { + if (verdicts.includes('mono')) return 'mono'; + const decided = verdicts.length > 0; + return decided && verdicts.every((v) => v === 'stop') ? 'stop' : 'open'; + }; + // A custom property set on the reading rule's own elements: by a rule + // with its selector in its file (see `selectorOf`), under no condition + // the reader does not share, to a value of its own. It replaces what + // those elements inherit. + const setsOwn = (definition, reference, chains = reference.selectors) => + definition.file === reference.file && + covers(definition.selectors, chains) && + !INHERITING.test((definition.full ?? definition.value).trim()) && + (definition.guards ?? []).every((guard) => + (reference.guards ?? []).includes(guard) + ); + // How near the reading rule's elements a definition sets a custom + // property: a registered initial value lowest (it applies where nothing + // is set), then `:root`, `body`, an enclosing rule in the reader's file + // (the innermost nearest) and `*` on the element itself; `null` for one + // whose place relative to them is unknown (another rule, code). + const nearness = (definition, reference) => { + if (definition.registered) return 0; + const depth = reachDepth(definition.rule, reference.rule); + if (depth !== null) return depth; + const own = definition.file === reference.file; + const at = own + ? (reference.scopes ?? []).indexOf(definition.scope) + : -1; + return at > 0 ? 3 - at / 1000 : null; + }; + // The definitions that can still apply once the nearest one set + // unconditionally to a value of its own hides the farther ones. + // A property registered with `inherits: false` reaches no element from + // an ancestor (`:root`, `body`, an enclosing rule): only what the element + // sets itself (`*`, code) or the initial value. + const nearest = (visible, reference) => { + // An explicit `inherit` on the reader's own rule still takes the + // parent's value (`unset` and `revert` give the initial one). + const inherited = visible.some( + (d) => + d.file === reference.file && + covers(d.selectors, reference.selectors) && + /^inherit\b/i.test((d.full ?? d.value).trim()) + ); + const local = + !inherited && visible.some((d) => d.registered && !d.inherits); + const ranks = visible.map((d) => nearness(d, reference)); + if (local) { + const ancestor = (rank) => rank !== null && rank > 0 && rank < 4; + return visible.filter((_, i) => !ancestor(ranks[i])); + } + const settled = visible + .map((definition, i) => ({ definition, rank: ranks[i] })) + .filter(({ definition, rank }) => { + const text = (definition.full ?? definition.value).trim(); + const shared = (definition.guards ?? []).every( + (guard) => + definition.file === reference.file && + (reference.guards ?? []).includes(guard) + ); + return rank !== null && shared && !INHERITING.test(text); + }); + const top = Math.max(-1, ...settled.map(({ rank }) => rank)); + return visible.filter((_, i) => ranks[i] === null || ranks[i] >= top); + }; + const refVerdict = (reference, seen) => { + const { name, file, namespace, index, shorthand } = reference; + const key = `${file} ${namespace ?? ''} ${index} ${name} ${shorthand}`; + if (seen.has(key)) return 'open'; + seen.add(key); + // A value the elements set themselves hides inherited ones, and of + // those the nearest settled one hides the farther (see `nearest`); + // one set from code (an inline style) may still win over either. + const visible = visibleDefinitions(reference); + let definitions = visible; + if (name.startsWith('--')) { + // Each of the reader's selectors (`.x, .y`) is its own elements: + // a value one sets there, and not replaced there, hides what + // those inherit, while one with none of its own inherits. + const chains = reference.selectors ?? []; + const ownFor = chains.map((chain) => + visible.filter( + (definition) => + setsOwn(definition, reference, [chain]) && + !replacedFor(definition, [chain]) + ) + ); + const inherited = + !chains.length || ownFor.some((own) => !own.length) + ? nearest(visible, reference) + : []; + const kept = new Set([...ownFor.flat(), ...inherited]); + definitions = visible.filter((d) => d.code || kept.has(d)); + } + // A value set from code holds the text of its strings. + const verdicts = definitions.flatMap((definition) => + (definition.code + ? cssOfCode(definition.value) + : [definition.full ?? definition.value] + ).map((text) => familyVerdict(text, definition, seen, shorthand)) + ); + if (reference.fallback !== null && mayBeUnset(reference)) { + verdicts.push( + familyVerdict(reference.fallback, reference, seen, shorthand) + ); + } + return combine(verdicts); + }; + const familyVerdict = (text, at, seen, shorthand = false) => { + const parts = familyParts(text); + // A shorthand that is all variables: each holds a whole shorthand. + if (shorthand && !hasLiteralSize(parts.outside)) { + const list = shorthandFamilies(parts.outside); + if (list === null || /^\s*$/.test(list)) { + const refs = familyRefs(parts, { ...at, shorthand: true }); + return combine(refs.map((ref) => refVerdict(ref, seen))); + } + } + const list = shorthand ? shorthandFamilies(text) : text; + if (list === null) return 'open'; + for (const entry of familyEntries(list)) { + const verdict = + entryVerdict(entry) ?? variableVerdict(entry, at, seen); + if (verdict !== 'open') return verdict; + } + return 'open'; + }; + // A variable entry: the lists its variables hold, or that Sass composes + // from it (`'#{$prefix} Mono'`, `$a $b`). + const variableVerdict = (entry, at, seen) => { + const composed = /\$/.test(entry) + ? composedFamilies(entry, at).map((t) => familyVerdict(t, at, seen)) + : []; + const refs = familyRefs(familyParts(entry), { + ...at, + shorthand: false, + }); + return combine([ + ...composed, + ...refs.map((ref) => refVerdict(ref, seen)), + ]); + }; + const familyIsMono = (text, at, seen, shorthand = false) => + familyVerdict(text, at, seen, shorthand) === 'mono'; + // A family Sass assembles from variables (`'#{$prefix} Mono'`, `$a $b`), + // with each variable replaced by a value it can hold (up to 16 + // combinations, through chains of any length, a cycle aside), so the + // name is read whole. One the scan cannot resolve (a package module) + // leaves nothing to compose. + const composedFamilies = (text, at, path = new Set()) => { + let results = ['']; + let last = 0; + for (const match of text.matchAll(SASS_VALUE)) { + const full = match[1] ?? match[2]; + const dot = full.lastIndexOf('.'); + const reference = { + name: identity(full.slice(dot + 1)), + namespace: dot === -1 ? null : full.slice(0, dot), + ...{ file: at.file, index: at.index, scopes: at.scopes }, + ...{ inCallable: at.inCallable, callable: at.callable }, + }; + const values = visibleDefinitions(reference) + .filter((definition) => !path.has(definition)) + .flatMap((definition) => + composedFamilies( + sassValue(definition.full ?? definition.value), + definition, + new Set([...path, definition]) + ) + ); + const between = text.slice(last, match.index); + results = results + .flatMap((result) => values.map((v) => result + between + v)) + .slice(0, 16); + last = match.index + match[0].length; + } + return results.map((result) => result + text.slice(last)); + }; + const followed = new Set(); + const findings = []; + for (const candidate of scans.flatMap((scan) => scan.deferred ?? [])) { + const { family, at, shorthand } = candidate; + if (!familyIsMono(family, at, new Set(), shorthand)) continue; + const { file, line, name } = candidate; + findings.push( + ...candidate.terms.map((term) => ({ file, line, name, ...term })) + ); + pending.push(...candidate.references); + } + while (pending.length > 0) { + const reference = pending.pop(); + const { name, mode, after = 0, file, namespace, index } = reference; + const { cap = null } = reference; + const sass = name.startsWith('$'); + // What a Sass name resolves to depends on where it is read. + const origin = sass ? `${file} ${namespace ?? ''} ${index}` : ''; + const key = `${mode} ${after} ${cap} ${origin} ${name}`; + if (followed.has(key)) continue; + followed.add(key); + for (const definition of visibleDefinitions(reference)) { + // A declaration's whole value (a list `400, 650` too); an + // argument's ends at its comma. + const value = definition.argument + ? definition.value + : (definition.full ?? definition.value); + // A shorthand that names its own family meets the Mono cap only + // when that family can render Mono (`--f: 700 16px Roboto` read + // where a fallback is Mono keeps its 700). + const own = mode === 'font' ? shorthandFamilies(value) : null; + const capHere = + own && !rendersMono(own) && !/var\(|\$/.test(own) ? null : cap; + const analysis = definition.range + ? rangeAnalysis(definition) + : definition.code + ? analyseCode(definition.value, mode, after, capHere) + : analyse(mode, value, { after, cap: capHere }); + const terms = definition.weighted + ? analysis.terms.filter(capOnly) + : analysis.terms; + for (const term of terms) { + const { line } = definition; + const at = { + file: definition.file, + line, + name: definition.name, + }; + findings.push({ ...at, ...term }); + } + pending.push( + ...analysis.references.map((next) => ({ + ...next, + file: definition.file, + index: definition.index, + scopes: definition.scopes, + inCallable: definition.inCallable, + callable: definition.callable, + })) + ); + } + } + return findings; +} + +/** Every off-scale weight one file can reach on its own. */ +export function findOffScaleWeights(file, source) { + const scan = scanWeights(file, source); + return { + declarations: scan.declarations, + findings: [...scan.findings, ...findIndirectWeights([scan])], + }; +} + +export function describeFinding(finding) { + const { file, line, name, value, computed, cap } = finding; + const scale = WEIGHT_SCALE.join('/'); + if (cap && !computed) { + return `${file}:${line} ${name}: ${value} is heavier than ${cap}, the heaviest JetBrains Mono face bundled, and a JetBrains Mono rule uses it, so Chromium fakes the bold. Use ${cap}.`; + } + if (computed) { + return `${file}:${line} ${name}: ${value} is computed, and the compiled value is what renders (Sass turns \`400 + 500\` into 900). Write a ${scale} weight.`; + } + if (/^(bolder|lighter)$/i.test(String(value))) { + return `${file}:${line} ${name}: ${value} is relative to the parent weight and can land off the ${scale} scale. Use an explicit scale weight.`; + } + return `${file}:${line} ${name}: ${value} is off the ${scale} scale, so the bundled faces render it as a neighbouring weight or a synthetic bold. Use ${nearestScaleWeight(Number(value))}.`; +} + +/** + * A check that scanned nothing must never report success: the workspace always + * contains stylesheets, so an empty listing means the file scan broke. + */ +export function validateScanCoverage(files) { + if (files.some((file) => STYLESHEET.test(file))) return []; + return [ + 'No stylesheets were scanned. The workspace always contains SCSS, so an empty listing means the file scan failed rather than that the policy passed.', + ]; +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + // No shell and no quoted pathspec: `cmd.exe` would pass quotes literally. + const files = execFileSync('git', ['ls-files', '--', 'apps', 'libs'], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }) + .split('\n') + .filter(Boolean) + .filter(isScannedFile); + + const scans = []; + for (const file of files) { + const source = await readFile(path.resolve(rootDir, file), 'utf8'); + scans.push(scanWeights(file, source)); + } + const findings = [ + ...scans.flatMap((scan) => scan.findings), + ...findIndirectWeights(scans), + ]; + const diagnostics = [ + ...validateScanCoverage(files), + ...findings.map(describeFinding), + ]; + const declarations = scans.reduce( + (sum, scan) => sum + scan.declarations, + 0 + ); + + if (diagnostics.length > 0) { + console.error('Font weight scale check failed:'); + for (const diagnostic of diagnostics) console.error(`- ${diagnostic}`); + process.exitCode = 1; + } else { + console.log( + `Checked ${declarations} weight declarations across ${files.length} files; every weight is on the ${WEIGHT_SCALE.join('/')} scale and JetBrains Mono rules stay at ${MONO_WEIGHT_CAP} or lighter.` + ); + } +} diff --git a/tools/nx/check-font-weights.test.mjs b/tools/nx/check-font-weights.test.mjs new file mode 100644 index 000000000..84b4fd1fc --- /dev/null +++ b/tools/nx/check-font-weights.test.mjs @@ -0,0 +1,5750 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + MONO_WEIGHT_CAP, + blankComments, + describeFinding, + findIndirectWeights, + findOffScaleWeights, + isScannedFile, + nearestScaleWeight, + scanWeights, + validateScanCoverage, +} from './check-font-weights.mjs'; +import { decodeEscapes } from './font-weight-lexer.mjs'; + +const offScale = (file, source) => + findOffScaleWeights(file, source).findings.map( + ({ line, name, value }) => `${line} ${name}: ${value}` + ); + +test('flags an off-scale font-weight with its line number', () => { + const source = ['.title {', ' font-weight: 650;', '}'].join('\n'); + + assert.deepEqual(offScale('libs/a.component.scss', source), [ + '2 font-weight: 650', + ]); +}); + +test('accepts every scale weight and the keywords that resolve onto it', () => { + const source = [ + 'a { font-weight: 400; }', + 'b { font-weight: 500 !important; }', + 'c { font-weight: 600; }', + 'd { font-weight: 700; }', + 'e { font-weight: normal; }', + 'f { font-weight: bold; }', + 'g { font-weight: inherit; }', + 'h { font-weight: $title-weight; }', + 'i { font-weight: var(--error-view-title-weight); }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), []); +}); + +test('flags weights fed through custom properties, Sass and token maps', () => { + const source = [ + '.view {', + ' --error-empty-title-weight: 610;', + ' --error-view-title-weight: var(--error-empty-title-weight, 640);', + '}', + '@include detail-view.base(', + ' $season-title-font-weight: 650,', + ');', + '@include mat.button-overrides((label-text-weight: 530));', + '@include heading($title-weight: 650) {', + ' color: red;', + '}', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), [ + '2 --error-empty-title-weight: 610', + '3 --error-view-title-weight: 640', + '6 $season-title-font-weight: 650', + '8 label-text-weight: 530', + '9 $title-weight: 650', + ]); +}); + +test('reads the weight of a font shorthand but not its line height', () => { + const source = [ + '.a { font: 600 10.5px $font-mono; }', + '.b { font: 800 12px/1 sans-serif; }', + '.d { font: inherit; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), ['2 font: 800']); +}); + +test('reads a value wrapped over several lines to its end', () => { + const source = [ + '.a {', + ' font-weight: var(--title-weight,', + ' 650);', + '}', + '.b {', + ' font-weight:', + ' var(', + ' --title-weight,', + ' 750', + ' );', + '}', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), [ + '2 font-weight: 650', + '6 font-weight: 750', + ]); +}); + +test('stops a value at the end of its map entry or style string', () => { + const map = [ + '@include mat.button-overrides(', + ' (', + ' label-text-weight: 600,', + ' label-text-line-height: 1,', + ' )', + ');', + ].join('\n'); + const component = [ + "const style = 'font-weight: 600';", + 'const timeout = 300;', + ].join('\n'); + + const template = '

Top 100 channels

'; + + assert.deepEqual(offScale('libs/a.scss', map), []); + assert.deepEqual(offScale('apps/web/src/a.ts', component), []); + assert.deepEqual(offScale('apps/web/src/a.html', template), []); +}); + +test('checks the font shorthand in TypeScript, not a font property', () => { + const component = [ + "styles: ['.chip { font: 650 12px sans-serif; }'],", + 'const chart = { font: 12, legend: { font: { size: 12 } } };', + "const inherit = { font: 'inherit' };", + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 font: 650', + ]); +}); + +test('compares every CSS number form with the scale', () => { + const source = [ + '.a { font-weight: 650.0; }', + '.b { font-weight: 600.0; }', + '.c { font-weight: 6.5e2; }', + '.d { font-weight: +700; }', + '.e { font: 520.5 12px sans-serif; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), [ + '1 font-weight: 650.0', + '3 font-weight: 6.5e2', + '5 font: 520.5', + ]); +}); + +test('reads a declaration to its terminator however long it is', () => { + const source = `.a { font-weight: /* ${'x'.repeat(600)} */ 650; }`; + + assert.deepEqual(offScale('libs/a.scss', source), ['1 font-weight: 650']); +}); + +test('flags relative keywords in the font shorthand', () => { + const source = '.a { font: bolder 1rem sans-serif; }'; + + assert.deepEqual(offScale('libs/a.scss', source), ['1 font: bolder']); +}); + +test('follows variables a weight refers to, through chains and files', () => { + const source = [ + ':root { --title: var(--title-base); --title-base: 650; --gap: 3; }', + '$heading: 750;', + '.a { font-weight: var(--title); gap: var(--gap); }', + '.b { font-weight: $heading; }', + '.c { font: 600 var(--size) $family; }', + ':root { --size: 12; }', + '$family: 1;', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), [ + '2 $heading: 750', + '1 --title-base: 650', + ]); + + const tokens = scanWeights('libs/tokens.scss', ':root { --title: 650; }'); + const usage = scanWeights( + 'libs/b.component.scss', + '.b { font-weight: var(--title); }' + ); + assert.deepEqual( + findIndirectWeights([tokens, usage]).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ), + ['libs/tokens.scss:1 650'] + ); +}); + +test('checks Angular style bindings and literal DOM writes', () => { + const template = [ + '

a

', + '

b

', + ].join('\n'); + const component = [ + "host: { '[style.font-weight]': '750' },", + "element.style.fontWeight = '650';", + "element.style.setProperty('font-weight', '450');", + 'element.style.fontWeight = weight;', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ + '1 [style.font-weight]: 650', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 [style.font-weight]: 750', + '2 .style.fontWeight: 650', + "3 setProperty('font-weight': 450", + ]); +}); + +test('reads `//` after `:` or `(` as a Sass comment outside url()', () => { + const stylesheet = [ + '.x { font-weight:// old 650', + '600; }', + '.y { font-weight: (// note 650', + '600); }', + '.z { background: url(http://cdn.test/a.png); font-weight: 750; }', + '.w { color: red; } // a note that ends in url(', + '// font-weight: 650 was the old value', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', stylesheet), [ + '5 font-weight: 750', + ]); +}); + +test('reads a TypeScript style object value as code', () => { + const component = [ + 'const style = { fontWeight: viewportWidth >= 768 ? 700 : 600 };', + "const other = { 'font-weight': wide ? 650 : 400, color: 'red' };", + 'const css = `font-weight: ${w}; font: 650 12px x`;', + 'fontWeight: number;', + "const keyed = { ['font-weight']: 650, [`fontWeight`]: 750 };", + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.ts', component).sort(), + [ + '2 font-weight: 650', + '3 font: 650', + '5 font-weight: 650', + '5 fontWeight: 750', + ] + ); + // HTML outside attributes is markup, not code: a `' + ), + [] + ); +}); + +test('treats JetBrains Mono after an always-available family as a fallback', () => { + const source = [ + ".a { font: 700 16px Roboto, 'JetBrains Mono'; }", + ".b { font-family: monospace, 'JetBrains Mono'; font-weight: 700; }", + ".c { font-family: 'SF Mono', 'JetBrains Mono'; font-weight: 700; }", + ".d { font: 700 12px/1.4 'JetBrains Mono'; }", + ".e { font: 700 12px / 1.4 'JetBrains Mono'; }", + ".f { font: 700 12px / 1.4 Roboto, 'JetBrains Mono'; }", + ".g { font: 700 12px /1.4 Roboto, 'JetBrains Mono'; }", + ".h { font-family: 'Roboto', 'JetBrains Mono'; font-weight: 700; }", + // DM Sans is Latin-only: Russian or Greek text falls through to Mono. + ".i { font-family: 'DM Sans', 'JetBrains Mono'; font-weight: 700; }", + // A size from a variable: the family follows it. + ".j { font: 700 var(--size, 12px) 'JetBrains Mono'; }", + ".k { font: 700 var(--size, 12px) Roboto, 'JetBrains Mono'; }", + ".l { font: 700 $size 'JetBrains Mono'; }", + ".m { font: 700 var(--size) 'JetBrains Mono'; }", + // The name is matched whole. + ".n { font-family: 'Not JetBrains Mono'; font-weight: 700; }", + // `emoji` covers special glyphs only; text falls through to Mono. + ".o { font-family: emoji, 'JetBrains Mono'; font-weight: 700; }", + ].join('\n'); + + assert.deepEqual(offScale('libs/m6/a.scss', source).sort(), [ + '10 font: 700', + '12 font: 700', + '13 font: 700', + '15 font-weight: 700', + '3 font-weight: 700', + '4 font: 700', + '5 font: 700', + '9 font-weight: 700', + ]); + assert.deepEqual( + offScale( + 'apps/web/src/a.component.html', + `
` + ), + [] + ); +}); + +test('reads a family name as the browser does', () => { + const source = [ + // Escapes decode: these all name JetBrains Mono. + '.a { font-family: JetBrains\\ Mono; font-weight: 700; }', + '.b { font-family: \\4a etBrains Mono; font-weight: 700; }', + ".c { font-family: 'JetBrains\\20 Mono'; font-weight: 700; }", + '.d { font: 700 12px JetBrains\\ Mono; }', + // A quoted generic is a family name nobody has; an escaped one is + // still the keyword. + ".e { font-family: 'monospace', 'JetBrains Mono'; font-weight: 700; }", + ".f { font-family: \\73 erif, 'JetBrains Mono'; font-weight: 700; }", + // A string keeps its spaces and commas: neither is JetBrains Mono. + ".g { font-family: 'JetBrains Mono'; font-weight: 700; }", + ".h { font-family: 'JetBrains Mono, x'; font-weight: 700; }", + // Unquoted words join with one space; an escaped one is kept. + '.i { font-family: JetBrains Mono; font-weight: 700; }', + '.j { font: 700 12px JetBrains\\ Mono; }', + // Not one name: CSS drops the declaration. + ".k { font-family: 'JetBrains Mono' Bold; font-weight: 700; }", + '.l { font-family: JetBrains\\\n Mono; font-weight: 700; }', + // A string's escaped quote is text; a line break in a string + // continues it when escaped and ends it (unclosed) otherwise. + ".m { font-family: 'x\\', JetBrains Mono, y'; font-weight: 700; }", + ".n { font-family: 'JetBrains\\\n Mono'; font-weight: 700; }", + ".o { font-family: 'JetBrains Mono\n; font-weight: 700; }", + // An escape past the last code point is U+FFFD. + '.p { font-family: \\110000 JetBrains Mono; font-weight: 700; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/m6/escapes.scss', source).sort(), [ + '1 font-weight: 700', + '16 font-weight: 700', + '2 font-weight: 700', + '3 font-weight: 700', + '4 font: 700', + '5 font-weight: 700', + '9 font-weight: 700', + ]); +}); + +test('reads escaped names as Sass and the browser do', () => { + const source = [ + '.a { font-w\\65 ight: 650; fo\\6et-weight: 750; }', + '.b { font\\2d weight: 650; font-\\77 eight: 750; }', + '.c { --w\\65 ight: 650; font-weight: var(--weight); }', + '$w\\65: 750; .d { font-weight: $we; }', + ".e { font-family: 'JetBrains Mono'; font-weight: b\\6f ld; }", + // An escape that ends at a line break keeps later lines in place. + '.f { font-w\\65', + 'ight: 650; }', + '.g { font-weight: 750; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/m6/escaped.scss', source).sort(), [ + '1 font-weight: 650', + '1 font-weight: 750', + '2 font-weight: 650', + '2 font-weight: 750', + '3 --weight: 650', + '4 $we: 750', + '5 font-weight: bold', + '6 font-weight: 650', + '8 font-weight: 750', + ]); + assert.deepEqual( + offScale( + 'apps/web/src/a.component.html', + '

x

' + ), + ['1 font-weight: 650'] + ); + // An escape stays where its character would change the token: a digit + // starting a name, anything after a number, a character no name has, + // and past ASCII. TypeScript strings use JavaScript escapes instead. + const kept = [ + '\\36 50', + '6\\35 0', + '6\\65 2', + '-\\35', + '.a\\:b', + "'\\\\65'", + 'JetBrains\\20 Mono', + 'a\\10061', + ].join(' '); + assert.equal(decodeEscapes('a.scss', kept).text, kept); + assert.equal( + decodeEscapes('a.scss', '-\\77 x a\\31, \\5f x -w\\65 bkit').text, + '-wx a1, _x -webkit' + ); + assert.equal( + decodeEscapes('a.ts', 'font-w\\65 ight').text, + 'font-w\\65 ight' + ); + // A comment is no CSS, so an escape there cannot take the line break. + assert.deepEqual( + offScale('libs/m6/note.scss', '// note \\65\n.a { font-weight: 650; }'), + ['2 font-weight: 650'] + ); +}); + +test('reads markup character references as the browser does', () => { + const template = [ + '

', + '

', + '

', + '

', + '

', + "

", + // A decoded line break keeps later lines in place. + '

', + // Not decoded: `', + '

', + '', + '', + // `<` and `>` stay text: no tag opens or closes. + '

<i style="font-weight: 650">

', + '

', + // Past the last code point is U+FFFD. + '

', + ].join('\n'); + const svg = [ + 'x', + '', + '', + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.html', template).sort(), + [ + '1 font-weight: 650', + '13 font-weight: 750', + '14 font-weight: 650', + '2 font-weight: 650', + '3 font-weight: 750', + '4 font-weight: 650', + '5 font-weight: 700', + '6 font-weight: 700', + '7 font-weight: 650', + ] + ); + // Lines hold through references that decode to two UTF-16 units, and + // through references and escapes decoded together. + const emoji = '😀'.repeat(30); + assert.deepEqual( + offScale( + 'apps/web/src/a.component.html', + `

\n\n` + ), + ['2 font-weight: 650'] + ); + assert.deepEqual( + offScale( + 'apps/web/src/a.component.html', + '

\n' + ), + ['2 font-weight: 650'] + ); + assert.deepEqual(offScale('apps/web/src/assets/icon.svg', svg).sort(), [ + '1 font-weight: 650', + '2 font-weight: 750', + ]); +}); + +test('reads CSS text a string leaves to code', () => { + const template = [ + `
`, + `
`, + `
`, + `
`, + // An escaped quote stays in the string. + `
`, + // The rest of the expression is not part of the value. + `
`, + `
`, + `
`, + // Only a `+` concatenates. + `
`, + // A blank string adds nothing. + `
`, + `
`, + ].join('\n'); + const component = [ + "el.style.cssText = 'font-weight:' + 650;", + "el.style.cssText = 'font-weight: ' + (wide ? 750 : 600) + '; x: y';", + "el.style.cssText = 'font-weight:' + w;", + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.html', template).sort(), + [ + '1 font-weight: 650', + '10 font-weight: 650', + '11 font-weight: 750', + '2 font-weight: 750', + '3 font: 650', + '4 font-weight: 600', + '5 font-weight: 650', + ] + ); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 font-weight: 650', + '2 font-weight: 750', + ]); +}); + +test('reads Sass nested font properties', () => { + const source = [ + ".x { font: { family: 'JetBrains Mono'; weight: 700; } }", + ".z { font-family: 'JetBrains Mono'; font: { weight: 700; } }", + ".w { font: { family: 'JetBrains Mono'; } font-weight: 700; }", + '.y { font: 12px { weight: 650; } }', + '.u { font: { family: Roboto; weight: 700; } }', + // `family`/`weight` outside a `font` block are other properties. + ".t { family: 'JetBrains Mono'; font-weight: 700; }", + // A nested weight and the rule's own replace each other in order; + // a bare `weight:` is no weight at all. + ".r { font-family: 'JetBrains Mono'; font: { weight: 700; } font-weight: 500; }", + ".s { font-family: 'JetBrains Mono'; font-weight: 700; weight: 400; }", + ].join('\n'); + + assert.deepEqual(offScale('libs/n4/a.scss', source).sort(), [ + '1 weight: 700', + '2 weight: 700', + '3 font-weight: 700', + '4 weight: 650', + '8 font-weight: 700', + ]); +}); + +test('reads CSS in markup only where it styles', () => { + const template = [ + 'font-weight: 650', + '

x

', + '

x

', + '', + `
`, + '', + // A CSS string holds no comment; after `` it is markup again. + '', + '

/* text

', + '

y

', + 'font-weight: 650', + // An unquoted `style` value runs to a space or `>`. + '
x
', + '
y
', + '
z
', + // Text that reads like an attribute, and a non-style binding. + '

Set style=font-weight:650 here

', + `
`, + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.html', template).sort(), + [ + '11 font-weight: 650', + '3 font-weight: 650', + '4 font-weight: 750', + '5 font-weight: 650', + '7 font-weight: 750', + '9 font-weight: 650', + ] + ); +}); + +test('reads @property initial values', () => { + const report = (body) => + findOffScaleWeights('libs/p9/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + + assert.deepEqual( + report( + "@property --title-weight { syntax: ''; inherits: false; initial-value: 650; } .x { font-weight: var(--title-weight); }" + ), + ['--title-weight: 650'] + ); + assert.deepEqual( + report( + "@property --w { syntax: ''; initial-value: 750; inherits: false; } .y { font-weight: var(--w); }" + ), + ['--w: 750'] + ); + // A `` takes an exponent; an `` takes neither an + // exponent nor a fraction. + assert.deepEqual( + report( + "@property --w { syntax: ''; inherits: false; initial-value: 6.5e2; } .y { font-weight: var(--w); }" + ), + ['--w: 6.5e2'] + ); + assert.deepEqual( + report( + "@property --w { syntax: ''; inherits: false; initial-value: 650; } .y { font-weight: var(--w); }" + ), + ['--w: 650'] + ); + assert.deepEqual( + report( + "@property --w { syntax: ''; inherits: false; initial-value: 6.5e2; } .y { font-weight: var(--w, 600); }" + ), + [] + ); + // A math function of plain numbers is a valid initial value, and a + // computed weight; one with a unit is not valid. + assert.deepEqual( + report( + "@property --w { syntax: ''; inherits: false; initial-value: calc(600 + 50); } .y { font-weight: var(--w); }" + ), + ['--w: calc(600 + 50)'] + ); + assert.deepEqual( + report( + "@property --w { syntax: ''; inherits: false; initial-value: calc(1px + 2px); } .y { font-weight: var(--w, 600); }" + ), + [] + ); + assert.deepEqual( + report( + "@property --w { syntax: ''; inherits: false; initial-value: calc(50% + 600); } .y { font-weight: var(--w, 600); }" + ), + [] + ); + // It parses as CSS does: arguments per function, and spaces around a + // `+` or `-`. Each value's validity is what Chromium makes of it. + const math = { + valid: [ + ...['calc(600 - -50)', 'calc(600 * 1.08)', 'calc((600 + 50))'], + ...['round(up, 649.2)', 'round(649.6, 10)', 'log(e)', 'max(650)'], + ...['calc(pi * 207)', 'MAX(600, 650)', 'calc(1300 / 2)'], + ...['hypot(650)', 'calc(1e3 - 350)', 'clamp(600, 650, 700)'], + ], + invalid: [ + ...['min()', 'calc()', 'calc(600+50)', 'calc(600 +50)'], + ...['calc(600+ 50)', 'calc(600 +(50))', 'clamp(600, 650)'], + ...['clamp(600, 650, 700, 800)', 'max(600, )', 'calc(600 650)'], + ...['calc(650)%', 'calc(600 + 50))', 'round(up 649)', 'foo(650)'], + ...['calc(600 $ 50)', 'round(up / 1, 649)'], + ], + }; + const registered = (value) => + report( + `@property --w { syntax: ''; inherits: false; initial-value: ${value}; } .y { font-weight: var(--w, 600); }` + ); + for (const value of math.valid) { + assert.deepEqual(registered(value), [`--w: ${value}`], value); + } + for (const value of math.invalid) { + assert.deepEqual(registered(value), [], value); + } + // CSS ignores an invalid registration: a syntax that rejects the value, + // or a missing `inherits`. + assert.deepEqual( + report( + "@property --title-weight { syntax: ''; inherits: false; initial-value: 650; } .x { font-weight: var(--title-weight, 600); }" + ), + [] + ); + assert.deepEqual( + report( + "@property --w { syntax: ''; initial-value: 750; } .y { font-weight: var(--w, 600); }" + ), + [] + ); + const reader = scanWeights( + 'libs/p9/b.scss', + ".b { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }" + ); + assert.deepEqual( + findIndirectWeights([ + scanWeights( + 'libs/p9/_props.scss', + "@property --face { syntax: ''; inherits: true; initial-value: Roboto; }" + ), + reader, + ]).map(({ value }) => value), + ['700'] + ); + // A registered property always has a value, so a fallback never + // applies, wherever it is registered. + assert.deepEqual( + findIndirectWeights([ + scanWeights( + 'libs/p9/_props.scss', + "@property --face { syntax: '*'; inherits: true; initial-value: Roboto; }" + ), + scanWeights( + 'libs/p9/b.scss', + ".b { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }" + ), + ]), + [] + ); +}); + +test('checks SVG presentation attributes and styles', () => { + const svg = [ + 'x', + '', + 'y', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/assets/logo.svg', svg).sort(), [ + '1 font-weight: 650', + '2 font-weight: 750', + ]); +}); + +test('caps inline JetBrains Mono declarations', () => { + const template = [ + `
`, + `
`, + '
', + `
`, + `
`, + `
`, + ].join('\n'); + const component = [ + `element.style.font = "700 16px 'JetBrains Mono'";`, + "styles: [`.a { font-family: 'JetBrains Mono'; } .b { font-weight: 700; }`],", + "styles: [`.c { font-family: 'JetBrains Mono'; font-weight: 600; }`],", + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ + '2 font-weight: 700', + '5 font-weight: 700', + '6 font-weight: 700', + '1 font: 700', + ]); + assert.deepEqual( + offScale('apps/web/src/a.component.ts', component).sort(), + ['1 .style.font: 700', '3 font-weight: 600'] + ); +}); + +test('reads Angular style bindings as code', () => { + const template = [ + '
', + `
`, + `
`, + '

text

', + `
`, + // A string in a binding is CSS, so `/200` is a line-height. + '
', + `
`, + ].join('\n'); + const component = + 'template: `
`,'; + + assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ + '2 font-weight: 650', + '3 font-weight: 750', + '4 font-weight: 650', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), []); +}); + +test('reads a feature query test as a condition, not a declaration', () => { + const source = [ + '@supports (font-weight: 650) { .x { font-weight: 600; } }', + '@supports (min-width: #{10}px) and (font-weight: 650) { .v { font-weight: 600; } }', + '@supports (font: 650 1px x) { .y { font: 600 12px x; } }', + '@container style(--w: 650) { .z { font-weight: var(--w); } }', + '@mixin m($title-weight: 400) { font-weight: $title-weight; }', + '.w { @include m($title-weight: 650); }', + ].join('\n'); + + assert.deepEqual(offScale('libs/q3/a.scss', source), [ + '6 $title-weight: 650', + ]); +}); + +test('reads an interpolated name every way it can compose', () => { + assert.deepEqual( + offScale( + 'libs/q3/c.scss', + '$prop: font; @if $dark { $prop: font-weight; } .x { #{$prop}: 750; }' + ), + ['1 #{$prop}: 750'] + ); +}); + +test('checks property names Sass builds by interpolation', () => { + const source = [ + '$prop: font-weight;', + '.z { #{$prop}: 650; }', + '.x { font-#{weight}: 650; }', + '.y { #{"font-weight"}: 750; }', + '.w { margin-#{left}: 650px; }', + '.v { font-#{$elsewhere}: 650; }', + '.u { font-#{weight}: 600; }', + // Another module's variable is not guessed; a literal `…weight` + // tail is checked as written, once. + '.t { font#{$elsewhere}: 650 12px x; }', + '.s { #{$elsewhere}-weight: 650; }', + '$pre: title; .r { #{$pre}-weight: 650; }', + // A variable is read as it stands where the name is built. + '$p: font-weight; .a { #{$p}: 650; } $p: margin-left;', + '$q: margin-left; .b { #{$q}: 650; } $q: font-weight;', + '$r: font-weight; .c { $r: color; } .d { #{$r}: 650; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/q3/b.scss', source).sort(), [ + '10 -weight: 650', + '11 #{$p}: 650', + '13 #{$r}: 650', + '2 #{$prop}: 650', + '3 font-#{weight}: 650', + '4 #{"font-weight"}: 750', + '9 -weight: 650', + ]); +}); + +test('follows Sass loop variables', () => { + const source = [ + '@each $w in 400, 650 { .x-#{$w} { font-weight: $w; } }', + '@each $w in 400, 600 { .y-#{$w} { font-weight: $w; } }', + '@each $n, $w in (light: 400, heavy: 650) { .t-#{$n} { font-weight: $w; } }', + '$weights: 400, 750;', + '@each $w in $weights { .u-#{$w} { font-weight: $w; } }', + '@for $w from 400 through 402 { .z-#{$w} { font-weight: $w; } }', + '@for $w from 600 through 600 { .s { font-weight: $w; } }', + '@for $i from 1 through 3 { .m-#{$i} { margin: $i * 4px; } }', + '@for $w from 650 through 650 { .r { font-weight: $w; } }', + // Inside its loop the variable shadows an outer one; after it, the + // outer one is back. + '$o: 650; @each $o in 400, 600 { .i { font-weight: $o; } }', + '$v: 600; @each $v in 650 { .in { color: red; } } .after { font-weight: $v; }', + '$p: margin; @each $p in font-weight, color { .l { #{$p}: 650; } }', + '$q: margin; @each $q in font-weight { .in { color: red; } } .w { #{$q}: 650; }', + // Destructuring: a map's keys and values, a list's positions. + '@each $name, $w in (650: 400, 750: 600) { .k-#{$name} { font-weight: $w; } }', + '@each $a, $w in (x 400, y 650) { .p { font-weight: $w; } }', + '@each $prop, $value in (font-weight: 750, color: red) { .q { #{$prop}: $value; } }', + '@each $key, $w in ((a b): 400, (c d): 650) { .pk { font-weight: $w; } }', + '@each $k, $w in ((x: 650): 400, (y: 1): 600) { .nk { font-weight: $w; } }', + // `to` stops before its end, so this loop never runs. + '@for $w from 650 to 650 { .e { font-weight: $w; } }', + // A space-separated list is one variable's list too. + '@each $w in 400 650 600 { .s { font-weight: $w; } }', + // A quoted element is one, its spaces, commas and colons included. + '@each $label, $w in ("wide label" 650,) { .ql { font-weight: $w; } }', + '@each $label, $w in ("a, b" 600, "c" 750) { .qc { font-weight: $w; } }', + '@each $k, $w in ("a: b": 650, c: 600) { .qk { font-weight: $w; } }', + '@each $l, $w in ("a\\", b" 600, "c" 750) { .qe { font-weight: $w; } }', + ].join('\n'); + + assert.deepEqual(offScale('libs/l3/a.scss', source).sort(), [ + '1 $w: 650', + '12 #{$p}: 650', + '15 $w: 650', + '16 $value: 750', + '16 font-weight: 750', + '17 $w: 650', + '20 $w: 650', + '21 $w: 650', + '22 $w: 750', + '23 $w: 650', + '24 $w: 750', + '3 $w: 650', + '4 $weights: 750', + '6 $w: from 400 through 402', + '9 $w: from 650 through 650', + ]); +}); + +test('treats comment markers inside strings as text', () => { + const stylesheet = [ + '.x { content: "//"; font-weight: 650; }', + '.y { background: url(//cdn.test/a.png); font-weight: 750; }', + '.z::after { content: "font-weight: 650"; }', + ].join('\n'); + const component = `styles: ['.x { content: "//"; font-weight: 650; }'],`; + + assert.deepEqual(offScale('libs/a.scss', stylesheet), [ + '1 font-weight: 650', + '2 font-weight: 750', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 font-weight: 650', + ]); +}); + +test('keeps a quoted family inside the font shorthand', () => { + const stylesheet = ".a { font: 650 12px 'DM Sans', sans-serif; }"; + const component = `styles: ['.a { font: 650 12px "DM Sans"; }'],`; + const template = [ + "

Don't wrap

", + '

x

', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', stylesheet), ['1 font: 650']); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 font: 650', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ + '2 font: 750', + ]); +}); + +test('reads only the tokens before the shorthand size as its weight', () => { + const valid = [ + '.a { font: 400 16px/1.5 "DM Sans"; }', + '.b { font: 400 16px/1.5 Helvetica Neue; }', + ".c { font: italic 600 1rem/1.25 'Open Sans', sans-serif; }", + '.d { font: 500 small/1.4 Inter; }', + '.e { font: 700 calc(1rem + 2px)/1.2 Inter; }', + '.f { font: 600 16px / 1.5 Helvetica Neue; }', + ].join('\n'); + const heavy = [ + '.a { font: 650 16px/1.5 "DM Sans"; }', + '.b { font: small-caps 750 12px Helvetica Neue; }', + '.c { font: 520 var(--size) / 1.5 "DM Sans"; }', + ].join('\n'); + + // Split at its space, the family would pass the size as a weight. + const zeroSize = ':root { --size: 0; } .z { font: var(--size) "DM Sans"; }'; + + assert.deepEqual(offScale('libs/a.scss', valid), []); + assert.deepEqual(offScale('libs/z.scss', zeroSize), []); + assert.deepEqual(offScale('libs/b.scss', heavy), [ + '1 font: 650', + '2 font: 750', + '3 font: 520', + ]); +}); + +test('follows a shorthand that is one variable, fallback included', () => { + const source = [ + ':root { --body-font: 650 1rem sans-serif; }', + '$heading-font: 750 1.2rem sans-serif;', + '.a { font: var(--body-font); }', + '.b { font: var(--caption-font, 520 0.8rem sans-serif); }', + '.c { font: $heading-font; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), [ + '4 font: 520', + '2 $heading-font: 750', + '1 --body-font: 650', + ]); +}); + +test('matches CSS names, keywords and functions in any case', () => { + const source = [ + '.a { font-weight: BOLDER; }', + '.b { font: LiGhTeR 1rem sans-serif; }', + '.c { FONT-WEIGHT: 650; }', + ':root { --title: 750; }', + '.d { font-weight: VAR(--title); }', + '.e { FONT: 650 12px sans-serif; }', + '.f { FONT: 400 16px/1.5 Inter; }', + ].join('\n'); + const findings = findOffScaleWeights('libs/a.scss', source).findings; + + assert.deepEqual( + findings.map(({ line, name, value }) => `${line} ${name}: ${value}`), + [ + '1 font-weight: BOLDER', + '3 FONT-WEIGHT: 650', + '2 font: LiGhTeR', + '6 FONT: 650', + '4 --title: 750', + ] + ); + assert.match(describeFinding(findings[0]), /relative to the parent/); +}); + +test('reads numbers and variables inside interpolation', () => { + const stylesheet = [ + '$w: 750;', + '.a { font-weight: #{650}; }', + '.b { font-weight: #{$w}; }', + '.c { font-weight: #{600}; }', + ].join('\n'); + const component = [ + 'styles: [`', + ' .a { font-weight: ${650}; }', + ' .b { font-weight: ${700}; }', + ' .c { font-weight: ${this.weight()}; }', + '`],', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', stylesheet), [ + '2 font-weight: 650', + '1 $w: 750', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '2 font-weight: 650', + ]); +}); + +test('reports a computed weight whole, since Sass compiles it', () => { + const computed = [ + '.a { font-weight: 400 + 500; }', + '.b { font-weight: 400+200; }', + '.c { font-weight: $base * 1.5; }', + '.d { font-weight: math.div(1300, 2); }', + '.e { font-weight: calc(600 + 50); }', + '.f { font-weight: map.get($weights, title); }', + '.g { font-weight: #{400 + 300}; }', + '.h { font-weight: var(--x, 400 + 300); }', + '.i { font: 400 + 200 12px sans-serif; }', + '.j { font-weight: -(-650); }', + '.k { font-weight: -650; }', + '.l { font-weight: +(650); }', + ].join('\n'); + const written = [ + '.a { font-weight: +700; }', + '.b { font-weight: var(--title-weight, 600); }', + '.c { font-weight: $font-weight; }', + '.d { font-weight: 600 !important; }', + '.e { font-weight: #{$font-weight}; }', + ].join('\n'); + const findings = findOffScaleWeights('libs/a.scss', computed).findings; + + assert.deepEqual( + findings.map(({ line, value }) => `${line} ${value}`), + [ + '1 400 + 500', + '2 400+200', + '3 $base * 1.5', + '4 math.div(1300, 2)', + '5 calc(600 + 50)', + '6 map.get($weights, title)', + '7 #{400 + 300}', + '8 var(--x, 400 + 300)', + '10 -(-650)', + '11 -650', + '12 +(650)', + '9 400 + 200', + ] + ); + assert.match(describeFinding(findings[0]), /is computed/); + assert.deepEqual(offScale('libs/b.scss', written), []); +}); + +test('treats `-` and `_` in Sass names alike, not in custom properties', () => { + const source = [ + '$heavy_value: 650;', + '$light-value: 750;', + ':root { --a_b: 650; }', + '.x { font-weight: $heavy-value; }', + '.y { font-weight: $light_value; }', + '.z { font-weight: var(--a-b); }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), [ + '2 $light-value: 750', + '1 $heavy_value: 650', + ]); +}); + +test('follows a variable for any stretch of the font shorthand', () => { + const source = [ + '$a-font: 650 1rem sans-serif;', + ':root { --b-font: 750 1rem sans-serif; }', + '$c-font: 520 1rem sans-serif;', + '$d-weight-and-size: 450 12px;', + '.a { font: italic $a-font; }', + '.b { font: italic var(--b-font); }', + '.c { font: italic #{$c-font}; }', + '.d { font: italic $d-weight-and-size Inter; }', + ].join('\n'); + const sizeAndFamily = [ + ':root { --size: 0; }', + "$family: 'DM Sans';", + '.e { font: 600 var(--size) $family; }', + '.f { font: italic 600 var(--size) Helvetica Neue; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source).sort(), [ + '1 $a-font: 650', + '2 --b-font: 750', + '3 $c-font: 520', + '4 $d-weight-and-size: 450', + ]); + assert.deepEqual(offScale('libs/b.scss', sizeAndFamily), []); +}); + +test('terminates on cyclic variables', { timeout: 5000 }, () => { + const source = [ + ':root { --body: var(--body) sans-serif; }', + '$a: $b 1rem;', + '$b: $a sans-serif;', + '.x { font: var(--body); }', + '.y { font: italic $a Inter; }', + '.z { font-weight: var(--body); }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), []); +}); + +test('reports arithmetic in code weights, not calls or conditions', () => { + const template = [ + '

a

', + '

b

', + ].join('\n'); + const component = [ + 'element.style.fontWeight = 600 + 50;', + "element.style.setProperty('font-weight', String(base + 100));", + 'element.style.fontWeight = this.weight();', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ + '1 [style.font-weight]: 600 + 50', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 .style.fontWeight: 600 + 50', + "2 setProperty('font-weight': String(base + 100)", + ]); +}); + +test('resolves Sass variables within their module scope', () => { + const scan = (file, source) => scanWeights(file, source); + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + + // Same name, unrelated files: the spacing value never feeds the weight. + const own = scan( + 'libs/a/a.scss', + '$local: 600; .a { font-weight: $local; }' + ); + const other = scan('libs/b/b.scss', '$local: 650; .b { margin: $local; }'); + assert.deepEqual(report([own, other]), []); + + // A loaded module, through a namespace. + const tokens = scan('libs/c/_tokens.scss', '$title: 650;'); + const user = scan( + 'libs/c/c.scss', + "@use './tokens' as t; .c { font-weight: t.$title; }" + ); + assert.deepEqual(report([tokens, user]), ['libs/c/_tokens.scss:1 650']); + + // A mixin argument passed by the file that loads the mixin. + const mixin = scan( + 'libs/d/_heading.scss', + '@mixin heading($w: 500) { font-weight: $w; }' + ); + const caller = scan( + 'libs/d/d.scss', + "@use './heading'; .d { @include heading.heading($w: 750); }" + ); + assert.deepEqual(report([mixin, caller]), ['libs/d/d.scss:1 750']); + + // Two files that only share a partial are not connected. + const shared = scan('libs/e/_shared.scss', '$gap: 4px;'); + const reader = scan( + 'libs/e/x.scss', + "@use './shared'; .x { font-weight: $v; }" + ); + const sibling = scan( + 'libs/e/y.scss', + "@use './shared'; $v: 650; .y { margin: $v; }" + ); + assert.deepEqual(report([shared, reader, sibling]), []); +}); + +test('reads runtime writes to the end of the statement', () => { + const component = [ + 'element.style.fontWeight =', + ' 650;', + 'element.style.fontWeight = 600 +', + ' 50;', + 'element.style.fontWeight = 600', + ' + 50;', + 'element.style.setProperty(', + " 'font-weight',", + ' 750', + ');', + 'element.style.fontWeight += 50;', + "if (element.style.fontWeight === '650') {}", + "if (element.style.fontWeight !== '650') {}", + '[style.font-weight]="25 ** 2"', + 'element.style.fontWeight = 2 ** exponent;', + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.ts', component).sort(), + [ + '1 .style.fontWeight: 650', + '11 .style.fontWeight: += 50', + '14 [style.font-weight]: 25 ** 2', + '15 .style.fontWeight: 2 ** exponent', + '3 .style.fontWeight: 600 +\n 50', + '5 .style.fontWeight: 600\n + 50', + "7 setProperty('font-weight': 750", + ] + ); +}); + +test('follows bare Sass loads and forwards', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const tokens = scanWeights('libs/c/_tokens.scss', '$heavy: 650;'); + const user = scanWeights( + 'libs/c/c.scss', + "@use 'tokens' as t; .c { font-weight: t.$heavy; }" + ); + const index = scanWeights('libs/s/_index.scss', "@forward 'panel-header';"); + const header = scanWeights('libs/s/_panel-header.scss', '$title: 750;'); + const consumer = scanWeights( + 'libs/x/x.scss', + "@use '../s' as s; .x { font-weight: s.$title; }" + ); + + assert.deepEqual(report([tokens, user]), ['libs/c/_tokens.scss:1 650']); + assert.deepEqual(report([index, header, consumer]), [ + 'libs/s/_panel-header.scss:1 750', + ]); +}); + +test('sees only what a loader passes into a loaded module', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + // `@use` never injects the loader's own variables. + const heading = scanWeights( + 'libs/h/_heading.scss', + '.h { font-weight: $local; }' + ); + const caller = scanWeights( + 'libs/h/h.scss', + "@use './heading'; $local: 650; .x { margin: $local; }" + ); + // A `with (…)` configuration does pass one in. + const config = scanWeights( + 'libs/f/_config.scss', + '$w: 500 !default; .f { font-weight: $w; }' + ); + const configured = scanWeights( + 'libs/f/f.scss', + "@use './config' with ($w: 650);" + ); + // Legacy `@import` is textual, so the importer's declarations count. + const part = scanWeights('libs/g/_part.scss', '.g { font-weight: $v; }'); + const importer = scanWeights('libs/g/g.scss', "$v: 750; @import 'part';"); + // An interpolated value is still the configuration. + const heavy = scanWeights( + 'libs/f/_heavy.scss', + '$w: 650 !default; .f { font-weight: $w; }' + ); + const interpolated = (value) => + scanWeights('libs/f/i.scss', `@use './heavy' with ($w: #{${value}});`); + + assert.deepEqual(report([heading, caller]), []); + assert.deepEqual(report([config, configured]), ['libs/f/f.scss:1 650']); + assert.deepEqual(report([heavy, interpolated(600)]), []); + assert.deepEqual(report([heavy, interpolated(750)]), [ + 'libs/f/i.scss:1 750', + ]); + assert.deepEqual(report([part, importer]), ['libs/g/g.scss:1 750']); +}); + +test('resolves a module member only through its namespace', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const a = scanWeights( + 'libs/n/_a.scss', + '$heavy: 600; $font: 600 1rem sans-serif;' + ); + const b = scanWeights( + 'libs/n/_b.scss', + '$heavy: 650; $font: 750 1rem sans-serif;' + ); + const uses = (body) => + scanWeights('libs/n/n.scss', `@use 'a'; @use 'b'; ${body}`); + + assert.deepEqual(report([a, b, uses('.x { font-weight: a.$heavy; }')]), []); + assert.deepEqual(report([a, b, uses('.x { font-weight: b.$heavy; }')]), [ + 'libs/n/_b.scss:1 650', + ]); + assert.deepEqual(report([a, b, uses('.y { font: italic b.$font; }')]), [ + 'libs/n/_b.scss:1 750', + ]); + + const tokens = scanWeights('libs/m/_tokens.scss', '$heavy: 750;'); + const star = scanWeights( + 'libs/m/star.scss', + "@use 'tokens' as *; .x { font-weight: $heavy; }" + ); + // A namespaced `@use` adds nothing unqualified (Sass would not compile). + const plain = scanWeights( + 'libs/m/plain.scss', + "@use 'tokens'; .x { font-weight: $heavy; }" + ); + assert.deepEqual(report([tokens, star]), ['libs/m/_tokens.scss:1 750']); + assert.deepEqual(report([tokens, plain]), []); +}); + +test('checks font-weight presentation attributes', () => { + const template = [ + 'a', + "b", + 'c', + '

d

', + 'e', + 'f', + '

font-weight=750 and font-weight="650" as text

', + 'g', + '

a < b

a < b font-weight=650

', + ].join('\n'); + const component = [ + `template: 'x',`, + 'template: " y\\" font-weight=\\"450\\">"', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.html', template), [ + '1 font-weight: 650', + '5 font-weight: 750', + '8 font-weight: 520', + '3 [attr.font-weight]: 600 + 50', + '4 [style.font-weight]: 650', + ]); + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 font-weight: 750', + '2 font-weight: 450', + ]); + // Server-side artwork in system Arial is not app UI. + assert.equal( + isScannedFile( + 'apps/xtream-mock-server/src/app/generators/marketing.generator.ts' + ), + false + ); + assert.equal( + isScannedFile( + 'libs/shared/marketing-fixtures/src/lib/marketing-live-fixtures.ts' + ), + false + ); +}); + +test('counts a `with (…)` configuration of a namespaced module', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const tokens = scanWeights( + 'libs/w/_tokens.scss', + '$heavy: 500 !default; $heavy-x: 500 !default;' + ); + // Sass names match across `-`/`_`, configuration included. + const aliased = scanWeights( + 'libs/w/k.scss', + [ + "@use './tokens' with ($heavy_x: 750);", + '.k { font-weight: tokens.$heavy-x; }', + ].join('\n') + ); + const configured = scanWeights( + 'libs/w/w.scss', + [ + "@use './tokens' with ($heavy: 650);", + '.x { font-weight: tokens.$heavy; }', + ].join('\n') + ); + // A mixin argument of the same name is not a configuration. + const unrelated = scanWeights( + 'libs/w/u.scss', + [ + "@use './tokens';", + '.x { @include m($heavy: 650); font-weight: tokens.$heavy; }', + ].join('\n') + ); + // Configured through a forwarding module. + const index = scanWeights( + 'libs/v/_index.scss', + "@forward 'tokens' with ($light: 450 !default);" + ); + const forwarded = scanWeights( + 'libs/v/_tokens.scss', + '$light: 400 !default;' + ); + const user = scanWeights( + 'libs/v/v.scss', + "@use '.' as v; .y { font-weight: v.$light; }" + ); + + assert.deepEqual(report([tokens, configured]), ['libs/w/w.scss:1 650']); + assert.deepEqual(report([tokens, unrelated]), []); + assert.deepEqual(report([tokens, aliased]), ['libs/w/k.scss:1 750']); + assert.deepEqual(report([index, forwarded, user]), [ + 'libs/v/_index.scss:1 450', + ]); +}); + +test('follows custom properties set from code', () => { + const component = [ + "host: { '[style.--title-weight]': '650' },", + "element.style.setProperty('--card-weight', '750');", + "element.style.setProperty('--body-font', '520 1rem sans-serif');", + '

', + "element.style.setProperty('--gap', '12');", + // A string is CSS text: the family's number is not a weight. + "element.style.setProperty('--quiet-font', '600 1rem \"Font 900\"');", + ].join('\n'); + const stylesheet = [ + '.a { font-weight: var(--title); }', + '.b { font: var(--body-font); }', + '.c { font: var(--quiet-font); }', + ].join('\n'); + const scans = [ + scanWeights('apps/web/src/a.component.ts', component), + scanWeights('libs/a.scss', stylesheet), + ]; + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 [style.--title-weight]: 650', + "2 setProperty('--card-weight': 750", + ]); + assert.deepEqual( + findIndirectWeights(scans) + .map(({ file, line, value }) => `${file}:${line} ${value}`) + .sort(), + [ + 'apps/web/src/a.component.ts:3 520', + 'apps/web/src/a.component.ts:4 450', + ] + ); +}); + +test('reports signed and unary arithmetic in code weights', () => { + const component = [ + 'element.style.fontWeight = 600 - -50;', + '[style.font-weight]="600 - -50"', + 'element.style.fontWeight = -(-650);', + 'element.style.fontWeight = active ? -650 : 400;', + 'element.style.fontWeight = +700;', + 'element.style.fontWeight = 600 + -offset;', + 'element.style.fontWeight = 600 + +50;', + 'element.style.fontWeight = offset + +600;', + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.ts', component).sort(), + [ + '1 .style.fontWeight: 600 - -50', + '2 [style.font-weight]: 600 - -50', + '3 .style.fontWeight: -(-650)', + '4 .style.fontWeight: -650', + '6 .style.fontWeight: 600 + -offset', + '7 .style.fontWeight: 600 + +50', + '8 .style.fontWeight: offset + +600', + ] + ); +}); + +test('reads non-decimal JavaScript numbers as the weight they make', () => { + const component = [ + 'element.style.fontWeight = 0x28a;', + "renderer.setStyle(el, 'font-weight', 0o1212);", + 'element.style.fontWeight = 0b1010001010;', + 'element.style.fontWeight = 6_50;', + 'element.style.fontWeight = 650n;', + 'element.style.fontWeight = wide ? 0X28A : 400;', + 'element.style.fontWeight = 0x258 + 50;', + 'element.style.fontWeight = 0x258;', + 'element.style.fontWeight = 6_00;', + ].join('\n'); + + assert.deepEqual( + offScale('apps/web/src/a.component.ts', component).sort(), + [ + '1 .style.fontWeight: 650', + "2 setStyle(el,'font-weight': 650", + '3 .style.fontWeight: 650', + '4 .style.fontWeight: 650', + '5 .style.fontWeight: 650', + '6 .style.fontWeight: 650', + '7 .style.fontWeight: 600 + 50', + ] + ); +}); + +test('reads only the values a code weight can take', () => { + const component = [ + 'element.style.fontWeight = viewportWidth >= 768 ? 700 : 600;', + '[style.font-weight]="wide && width > 1024 ? 700 : 600"', + 'element.style.fontWeight = compact ? 600 : wide ? 650 : 700;', + 'element.style.fontWeight = (width >= 768 ? 750 : 600);', + 'element.style.fontWeight = item?.weight ?? 650;', + 'element.style.fontWeight = ready && 650;', + 'element.style.fontWeight = width > 1024;', + 'element.style.fontWeight = wide ? 600 + 50 : 600;', + "element.style.fontWeight = wide ? '650' : '600';", + 'element.style.fontWeight = 1300 >> 1;', + 'element.style.fontWeight = (width > 1024 && 650) || 700;', + 'element.style.fontWeight = a ? w > 768 ? 650 : 700 : 600;', + 'element.style.fontWeight = item?.width > 768 ? 650 : 600;', + 'element.style.fontWeight = flags & 1024 ? custom ?? 600 : 700;', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '3 .style.fontWeight: 650', + '4 .style.fontWeight: 750', + '5 .style.fontWeight: 650', + '6 .style.fontWeight: 650', + '8 .style.fontWeight: 600 + 50', + '9 .style.fontWeight: 650', + '10 .style.fontWeight: 1300', + '11 .style.fontWeight: 650', + '12 .style.fontWeight: 650', + '13 .style.fontWeight: 650', + ]); +}); + +test('checks setProperty with a template-literal name', () => { + const component = [ + "element.style.setProperty(`font-weight`, '650');", + "element.style.setProperty(`--title-weight`, '750');", + "element.style.setProperty(`font-weight`, '600');", + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '1 setProperty(`font-weight`: 650', + '2 setProperty(`--title-weight`: 750', + ]); +}); + +test('checks indexed style writes', () => { + const component = [ + "element.style['fontWeight'] = 650;", + 'element.style["font-weight"] = 750;', + 'element.style[`fontWeight`] += 50;', + "if (element.style['fontWeight'] === 650) {}", + "element.style['fontWeight'] = 600;", + "element.style.fontWeight ||= '650';", + "element.style['fontWeight'] ??= 750;", + "element.style.fontWeight &&= '600';", + "element.style.font = '650 16px sans-serif';", + "element.style['font'] = '600 12px x';", + "element.style.fontFamily = 'x 650';", + "element.style.font = 'italic 12px/200 x';", + 'element.style.font = `600 ${size}px/200 Roboto`;', + 'element.style.font = `650 ${size}px Roboto`;', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + "1 .style['fontWeight']: 650", + '2 .style["font-weight"]: 750', + '3 .style[`fontWeight`]: += 50', + '6 .style.fontWeight: 650', + "7 .style['fontWeight']: 750", + '9 .style.font: 650', + '14 .style.font: 650', + ]); +}); + +test('checks HostBinding style weights', () => { + const component = [ + "@HostBinding('style.fontWeight') weight = 650;", + '@HostBinding("style.font-weight") get heavy(): number { return this.on ? 700 : 750; }', + "@HostBinding('style.fontWeight') readonly ok = 600;", + "@HostBinding('attr.font-weight') svg = '650';", + "@HostBinding('class.bold') bold = 650;", + "@HostBinding('attr.--title-weight') odd = '650';", + "@HostBinding('style.fontWeight') get branchy() { if (this.on) { return 600; } return 650; }", + "@HostBinding('style.fontWeight') get quoted() { const s = 'return 650'; return 600; }", + "@HostBinding('style.fontWeight') get nested() { const f = () => { return 900; }; return 600; }", + "@HostBinding('style.fontWeight') get named() { function pick() { return 900; } return 600; }", + "@HostBinding('style.font') f = '650 12px x';", + "@HostBinding('style.fontWeight') get inIf() { if (this.on) { return 750; } return 600; }", + "@HostBinding('style.fontWeight') get typed() { function pick(): number { return 900; } return 600; }", + "@HostBinding('style.fontWeight') get shaped() { function pick(): { w: number } { return { w: 900 }; } return 600; }", + "@HostBinding('style.fontWeight') get later() { function pick(): Promise<{ w: number }> { return null; } return 650; }", + "@HostBinding('style.fontWeight') get arrowType() { function pick(): () => number { return () => 900; } return 600; }", + "@HostBinding('style.fontWeight') get bare() { const t = a ? f(b) : c; { return 750; } return 600; }", + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + "1 @HostBinding('style.fontWeight'): 650", + '2 @HostBinding("style.font-weight"): 750', + "4 @HostBinding('attr.font-weight'): 650", + "7 @HostBinding('style.fontWeight'): 650", + "11 @HostBinding('style.font'): 650", + "12 @HostBinding('style.fontWeight'): 750", + "15 @HostBinding('style.fontWeight'): 650", + "17 @HostBinding('style.fontWeight'): 750", + ]); +}); + +test('checks Renderer2 setStyle weights', () => { + const component = [ + "this.renderer.setStyle(this.host.nativeElement, 'fontWeight', 650);", + "renderer.setStyle(el(), 'font-weight', '750', RendererStyleFlags2.DashCase);", + "renderer.setStyle(el, 'fontWeight', 600);", + "renderer.setStyle(el, 'color', '650');", + "renderer.setStyle(wrap(getEl(a, b)), 'fontWeight', 650);", + "node.setAttributeNS(ns(), 'font-weight', '650');", + 'renderer.setStyle(el, `font-${kind}`, 650);', + "node.setAttribute('--title-weight', '650');", + "node.setAttributeNS('http://www.w3.org/2000/svg', 'font-weight', '650');", + "renderer.setStyle(el, 'font', '650 16px sans-serif');", + "el.style.setProperty('font', 'italic 12px/200 x');", + '[style.font]="\'650 12px x\'"', + "el.style.setProperty('font', '750 12px x');", + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '12 [style.font]: 650', + "1 setStyle(this.host.nativeElement,'fontWeight': 650", + "2 setStyle(el(),'font-weight': 750", + "5 setStyle(wrap(getEl(a,b)),'fontWeight': 650", + "6 setAttributeNS(ns(),'font-weight': 650", + "9 setAttributeNS('http://www.w3.org/2000/svg','font-weight': 650", + "10 setStyle(el,'font': 650", + "13 setProperty('font': 750", + ]); +}); + +test('checks font-weight attributes set at runtime', () => { + const component = [ + "node.setAttribute('font-weight', '650');", + 'node.setAttributeNS(null, "font-weight", 750);', + "node.setAttribute('font-weight', '600');", + "node.setAttribute('aria-label', '650');", + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + "1 setAttribute('font-weight': 650", + '2 setAttributeNS(null,"font-weight": 750', + ]); +}); + +test('counts only the configuration of the `@use` a member comes from', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const a = scanWeights( + 'libs/q/_alpha-palette.scss', + '$heavy: 500 !default;' + ); + const b = scanWeights('libs/q/_beta-palette.scss', '$heavy: 500 !default;'); + const user = (reference) => + scanWeights( + 'libs/q/q.scss', + [ + "/* Two palettes */ @use 'alpha-palette' as a with ($heavy: 600);", + "@use 'beta-palette' as b with ($heavy: 650); // heavy", + `.x { @include m($heavy: 750); font-weight: ${reference}; }`, + ].join('\n') + ); + + assert.deepEqual(report([a, b, user('a.$heavy')]), []); + assert.deepEqual(report([a, b, user('b.$heavy')]), ['libs/q/q.scss:2 650']); + // An earlier `@use`'s configuration does not configure a later one. + const reversed = scanWeights( + 'libs/q/r.scss', + "@use 'alpha-palette' as a with ($heavy: 650); @use 'beta-palette' as b with ($heavy: 600); .x { font-weight: b.$heavy; }" + ); + assert.deepEqual(report([a, b, reversed]), []); +}); + +test('resolves Sass variables within their block', () => { + const local = [ + '.spacing { $local: 650; margin: $local; }', + '.title { $local: 600; font-weight: $local; }', + ].join('\n'); + const nested = '.a { $w: 650; .b { font-weight: $w; } }'; + const global = '.a { $w: 750 !global; } .b { font-weight: $w; }'; + const argument = [ + '@mixin heading($w: 500) { font-weight: $w; }', + '.x { @include heading($w: 650); }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', local), []); + // Each block resolves its own `$w`, whichever is checked first. + const twoBlocks = [ + '.b { $w: 650; font-weight: $w; }', + '.a { $w: 600; font-weight: $w; }', + ].join('\n'); + assert.deepEqual(offScale('libs/f.scss', twoBlocks), ['1 $w: 650']); + assert.deepEqual(offScale('libs/b.scss', nested), ['1 $w: 650']); + assert.deepEqual(offScale('libs/c.scss', global), ['1 $w: 750']); + assert.deepEqual(offScale('libs/d.scss', argument), ['2 $w: 650']); + // A chain continues from where the intermediate variable is defined. + const chain = + '.a { $base: 650; $w: $base !global; } .b { font-weight: $w; }'; + assert.deepEqual(offScale('libs/e.scss', chain), ['1 $base: 650']); + + // A block-local declaration is not a module member. + const tokens = scanWeights( + 'libs/t/_tokens.scss', + '.x { $heavy: 650; margin: 0; padding: 0; color: red; }' + ); + const user = scanWeights( + 'libs/t/t.scss', + "@use 'tokens' as t; .y { font-weight: t.$heavy; }" + ); + assert.deepEqual(findIndirectWeights([tokens, user]), []); +}); + +test('runs a `!global` assignment in text order', () => { + const read = '.x { font-weight: $w; }'; + + for (const setter of [ + '.setter { $w: 600 !global; }', + '.a { .b { $w: 600 !global; } }', + ]) { + assert.deepEqual( + offScale('libs/g4/a.scss', `$w: 650; ${setter} ${read}`), + [] + ); + } + // Under flow control or in a mixin body it may not run first. + for (const setter of [ + '@if $flag { .s { $w: 600 !global; } }', + '@mixin set { $w: 600 !global; }', + ]) { + assert.deepEqual( + offScale('libs/g4/a.scss', `$w: 650; ${setter} ${read}`), + ['1 $w: 650'] + ); + } + assert.deepEqual( + offScale('libs/g4/a.scss', `.y { $w: 650 !global; } ${read}`), + ['1 $w: 650'] + ); +}); + +test('resolves the declaration in effect where a Sass name is read', () => { + const cases = { + // An inner declaration shadows the outer one. + shadowed: '$local: 650; .title { $local: 600; font-weight: $local; }', + // A reassignment after the reference does not reach it. + later: '.t { $w: 600; font-weight: $w; $w: 650; }', + // The last unconditional assignment wins. + reassigned: '$w: 650; $w: 600; .a { font-weight: $w; }', + heavier: '$w: 600; $w: 650; .a { font-weight: $w; }', + // Flow control assigns the enclosing variable, conditionally. + ifBlock: '.r { $w: 600; @if $c { $w: 650; } font-weight: $w; }', + // A conditional assignment does not hide the value before it. + ifKeepsOuter: '.r { $w: 650; @if $c { $w: 600; } font-weight: $w; }', + elseBlock: + '.r { $w: 600; @if $c { $w: 500; } @else { $w: 750; } font-weight: $w; }', + eachBlock: + '.r { $w: 600; @each $i in 1, 2 { $w: 700; } font-weight: $w; }', + topLevelIf: '$w: 600; @if $dark { $w: 520; } .a { font-weight: $w; }', + // A mixin body reads module variables when it is included. + mixin: '@mixin m { font-weight: $w; } $w: 650; .x { @include m; }', + mixinLocal: '@mixin m { $w: 600; font-weight: $w; } $w: 650;', + }; + const found = Object.fromEntries( + Object.entries(cases).map(([name, source]) => [ + name, + offScale(`libs/${name}.scss`, source), + ]) + ); + + assert.deepEqual(found, { + shadowed: [], + later: [], + reassigned: [], + heavier: ['1 $w: 650'], + ifBlock: ['1 $w: 650'], + ifKeepsOuter: ['1 $w: 650'], + elseBlock: ['1 $w: 750'], + eachBlock: [], + topLevelIf: ['1 $w: 520'], + mixin: ['1 $w: 650'], + mixinLocal: [], + }); + + // A file whose own declaration settles the value ignores an importer's. + const part = scanWeights( + 'libs/p/_part.scss', + '$v: 600; .g { font-weight: $v; }' + ); + const importer = scanWeights('libs/p/p.scss', "$v: 750; @import 'part';"); + assert.deepEqual(findIndirectWeights([part, importer]), []); +}); + +test('passes an argument only to the callable it is given to', () => { + const part = scanWeights( + 'libs/k/_part.scss', + '@mixin heading($w: 500) { font-weight: $w; }' + ); + const loader = scanWeights( + 'libs/k/k.scss', + "@use 'part'; .x { @include unrelated($w: 650); @include part.heading; }" + ); + const passing = scanWeights( + 'libs/k/p.scss', + "@use 'part'; .x { @include part.heading($w: 750); }" + ); + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + + assert.deepEqual(report([part, loader]), []); + assert.deepEqual(report([part, passing]), ['libs/k/p.scss:1 750']); + + const sameFile = [ + '@mixin heading($w: 500) { font-weight: $w; }', + '.x { @include other($w: 650); @include heading; }', + '.y { @include other($v: 650); } $v: 600; .z { font-weight: $v; }', + ].join('\n'); + assert.deepEqual(offScale('libs/same.scss', sameFile), []); +}); + +test('places imported declarations where the @import sits', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const part = scanWeights('libs/i/_part.scss', '$v: 950;'); + const after = scanWeights( + 'libs/i/after.scss', + "$v: 600; @import 'part'; .a { font-weight: $v; }" + ); + const before = scanWeights( + 'libs/i/before.scss', + "@import 'part'; $v: 600; .a { font-weight: $v; }" + ); + + assert.deepEqual(report([part, after]), ['libs/i/_part.scss:1 950']); + assert.deepEqual(report([part, before]), []); + + // A nested import takes effect where the outer `@import` sits. + const outer = scanWeights('libs/j/_outer.scss', "@import 'inner';"); + const inner = scanWeights('libs/j/_inner.scss', '$v: 950;'); + const main = scanWeights( + 'libs/j/main.scss', + "/* main */ $v: 600; @import 'outer'; .a { font-weight: $v; }" + ); + assert.deepEqual(report([outer, inner, main]), [ + 'libs/j/_inner.scss:1 950', + ]); +}); + +test('reads module variables where a mixin is called', () => { + // No caller in the file: the module's final value is what callers see. + const superseded = '$w: 650; $w: 600; @mixin m { font-weight: $w; }'; + // A call between the two assignments reads the first one. + const calledEarly = [ + '$w: 650; .x { @include m; } $w: 600;', + '@mixin m { font-weight: $w; }', + ].join('\n'); + + // Callers elsewhere see the module's final value. + const noCaller = '$w: 650; @mixin m { font-weight: $w; }'; + // A mixin's own signature is not a call. + const signature = '$w: 650; @mixin m($x: 1) { font-weight: $w; } $w: 600;'; + + assert.deepEqual(offScale('libs/m/superseded.scss', superseded), []); + assert.deepEqual(offScale('libs/m/no-caller.scss', noCaller), [ + '1 $w: 650', + ]); + assert.deepEqual(offScale('libs/m/signature.scss', signature), []); + assert.deepEqual(offScale('libs/m/early.scss', calledEarly), ['1 $w: 650']); +}); + +test('lets `!default` keep an earlier value', () => { + const cases = { + kept: '$heavy: 650; $heavy: 600 !default; .x { font-weight: $heavy; }', + unset: '$heavy: 600 !default; .x { font-weight: $heavy; }', + alone: '$heavy: 650 !default; .x { font-weight: $heavy; }', + // A mixin included after both assignments reads the final value. + included: + '$w: 650; $w: 600; @mixin heading { font-weight: $w; } .title { @include heading; }', + // A `!default` after a set value never applies, in any scope. + ignored: '$w: 600; $w: 650 !default; .x { font-weight: $w; }', + // `null` counts as unset, so a later `!default` applies. + afterNull: '$w: null; $w: 650 !default; .x { font-weight: $w; }', + resetToNull: + '$w: 600; $w: null; $w: 650 !default; .x { font-weight: $w; }', + innerIgnored: '$w: 600; .x { $w: 650 !default; font-weight: $w; }', + }; + const found = Object.fromEntries( + Object.entries(cases).map(([name, source]) => [ + name, + offScale(`libs/d/${name}.scss`, source), + ]) + ); + + assert.deepEqual(found, { + kept: ['1 $heavy: 650'], + unset: [], + alone: ['1 $heavy: 650'], + included: [], + ignored: [], + innerIgnored: [], + afterNull: ['1 $w: 650'], + resetToNull: ['1 $w: 650'], + }); + + // The partial's default yields to the value its importer set first. + const part = scanWeights( + 'libs/d/_part.scss', + '$v: 600 !default; .g { font-weight: $v; }' + ); + const importer = scanWeights( + 'libs/d/importer.scss', + "$v: 750; @import 'part';" + ); + assert.deepEqual( + findIndirectWeights([part, importer]).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ), + ['libs/d/importer.scss:1 750'] + ); +}); + +test('passes an argument to the module that owns the callable', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const a = scanWeights( + 'libs/o/_a.scss', + '@mixin heading($w: 1) { margin: $w; }' + ); + const b = scanWeights( + 'libs/o/_b.scss', + '@mixin heading($w: 600) { font-weight: $w; }' + ); + const loader = (body) => scanWeights('libs/o/o.scss', body); + + assert.deepEqual( + report([ + a, + b, + loader( + "@use 'a'; @use 'b'; .x { @include a.heading($w: 650); @include b.heading; }" + ), + ]), + [] + ); + assert.deepEqual( + report([a, b, loader("@use 'b'; .x { @include b.heading($w: 750); }")]), + ['libs/o/o.scss:1 750'] + ); + assert.deepEqual( + report([ + a, + b, + loader("@use 'b' as *; .x { @include heading($w: 750); }"), + ]), + ['libs/o/o.scss:1 750'] + ); + // A bare `heading(` also reaches a mixin in an `@import`ed file. + assert.deepEqual( + report([ + a, + b, + loader("@import 'b'; .x { @include heading($w: 750); }"), + ]), + ['libs/o/o.scss:1 750'] + ); + // A bare `heading(` resolves to `a` (`as *`), not to the namespaced `b`. + assert.deepEqual( + report([ + a, + b, + loader( + "@use 'a' as *; @use 'b'; .x { @include heading($w: 650); @include b.heading; }" + ), + ]), + [] + ); +}); + +test('counts the configuration of a module loaded `as *`', () => { + const tokens = scanWeights('libs/s2/_tokens.scss', '$w: 500 !default;'); + const loader = scanWeights( + 'libs/s2/s.scss', + "@use 'tokens' as * with ($w: 650); .x { font-weight: $w; }" + ); + + assert.deepEqual( + findIndirectWeights([tokens, loader]).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ), + ['libs/s2/s.scss:1 650'] + ); +}); + +test('runs a partial again at every @import', { timeout: 5000 }, () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const common = scanWeights('libs/r/_common.scss', '$w: 650;'); + const a = scanWeights('libs/r/_a.scss', "@import 'common';"); + const b = scanWeights('libs/r/_b.scss', "@import 'common';"); + const siblings = scanWeights( + 'libs/r/siblings.scss', + "@import 'a'; $w: 600; @import 'b'; .x { font-weight: $w; }" + ); + const repeated = scanWeights( + 'libs/r/repeated.scss', + "@import 'common'; $w: 600; @import 'common'; .x { font-weight: $w; }" + ); + const x = scanWeights('libs/c2/_x.scss', "@import 'y';"); + const y = scanWeights('libs/c2/_y.scss', "@import 'x'; $w: 600;"); + const cyclic = scanWeights( + 'libs/c2/main.scss', + "@import 'x'; .x { font-weight: $w; }" + ); + + assert.deepEqual(report([common, a, b, siblings]), [ + 'libs/r/_common.scss:1 650', + ]); + assert.deepEqual(report([common, repeated]), ['libs/r/_common.scss:1 650']); + assert.deepEqual(report([x, y, cyclic]), []); + + // Inside an inclusion, assignments keep their text order: the partial's + // own `$w: 650` comes after its nested import's `$w: 600`. + const nested = scanWeights('libs/r2/_nested.scss', '$w: 600;'); + const partial = scanWeights( + 'libs/r2/_partial.scss', + "@import 'nested'; $w: 650;" + ); + const reordered = scanWeights( + 'libs/r2/main.scss', + "@import 'partial'; .x { font-weight: $w; }" + ); + assert.deepEqual(report([nested, partial, reordered]), [ + 'libs/r2/_partial.scss:1 650', + ]); +}); + +test('runs a call inside a mixin body where that mixin runs', () => { + const deferred = [ + '$w: 650; @mixin caller { @include m; } $w: 600;', + '@mixin m { font-weight: $w; } .x { @include caller; }', + ].join('\n'); + const calledEarly = [ + '$w: 650; @mixin caller { @include m; } .x { @include caller; }', + '$w: 600; @mixin m { font-weight: $w; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/n2/deferred.scss', deferred), []); + assert.deepEqual(offScale('libs/n2/early.scss', calledEarly), [ + '1 $w: 650', + ]); +}); + +test('follows members forwarded under a prefix', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const base = scanWeights('libs/f2/_base.scss', '$heavy: 650;'); + const bundle = scanWeights( + 'libs/f2/_bundle.scss', + "@forward 'base' as prefix-*;" + ); + const outer = scanWeights( + 'libs/f2/_outer.scss', + "@forward 'bundle' as o-*;" + ); + const user = (body) => scanWeights('libs/f2/user.scss', body); + + assert.deepEqual( + report([ + base, + bundle, + user("@use 'bundle'; .x { font-weight: bundle.$prefix-heavy; }"), + ]), + ['libs/f2/_base.scss:1 650'] + ); + assert.deepEqual( + report([ + base, + bundle, + outer, + user("@use 'outer'; .x { font-weight: outer.$o-prefix-heavy; }"), + ]), + ['libs/f2/_base.scss:1 650'] + ); + // The forward exposes only the prefixed name. + assert.deepEqual( + report([ + base, + bundle, + user("@use 'bundle'; .x { font-weight: bundle.$heavy; }"), + ]), + [] + ); + assert.deepEqual( + report([ + base, + bundle, + user("@use 'bundle' as *; .x { font-weight: $prefix-heavy; }"), + ]), + ['libs/f2/_base.scss:1 650'] + ); + // One file brought in under two prefixes reads under either. + const other = scanWeights( + 'libs/f2/_other.scss', + "@forward 'base' as other-*;" + ); + assert.deepEqual( + report([ + base, + bundle, + other, + user( + "@use 'bundle' as *; @use 'other' as *; .x { font-weight: $prefix-heavy; }" + ), + ]), + ['libs/f2/_base.scss:1 650'] + ); +}); + +test('renders an imported rule where the @import sits', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const part = scanWeights( + 'libs/g2/_part.scss', + '$w: 600 !default; .g { font-weight: $w; }' + ); + const importer = scanWeights('libs/g2/i.scss', "@import 'part'; $w: 650;"); + // A partial's mixin runs when called, after the importer's assignment. + const mixin = scanWeights( + 'libs/g2/_mix.scss', + '@mixin m { font-weight: $w; }' + ); + const caller = scanWeights( + 'libs/g2/c.scss', + "@import 'mix'; $w: 650; .x { @include m; }" + ); + + assert.deepEqual(report([part, importer]), []); + assert.deepEqual(report([mixin, caller]), ['libs/g2/c.scss:1 650']); +}); + +test('skips a module default its configuration replaces', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const tokens = scanWeights('libs/w2/_tokens.scss', '$w: 650 !default;'); + const user = (body) => scanWeights('libs/w2/u.scss', body); + + assert.deepEqual( + report([ + tokens, + user( + "@use 'tokens' with ($w: 600); .x { font-weight: tokens.$w; }" + ), + ]), + [] + ); + assert.deepEqual( + report([ + tokens, + user("@use 'tokens' as * with ($w: 600); .x { font-weight: $w; }"), + ]), + [] + ); + assert.deepEqual( + report([tokens, user("@use 'tokens'; .x { font-weight: tokens.$w; }")]), + ['libs/w2/_tokens.scss:1 650'] + ); + // Another compilation loading it unconfigured does not change what + // this one reads. + assert.deepEqual( + report([ + tokens, + user( + "@use 'tokens' with ($w: 600); .x { font-weight: tokens.$w; }" + ), + scanWeights('libs/w2/other.scss', "@use 'tokens';"), + ]), + [] + ); +}); + +test('reads `-` and `_` in callable names alike', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const own = (body) => report([scanWeights('libs/h2/own.scss', body)]); + const base = scanWeights( + 'libs/h2/_base.scss', + '@mixin heading-style($w: 500) { font-weight: $w; }' + ); + const mod = scanWeights('libs/h2/_mod.scss', "@forward 'base' as p-*;"); + const user = (body) => scanWeights('libs/h2/user.scss', body); + + assert.deepEqual( + own( + '@mixin heading_style($w: 500) { font-weight: $w; } .x { @include heading-style($w: 650); }' + ), + ['libs/h2/own.scss:1 650'] + ); + // Module variables are read where the mixin is called, by either name. + for (const [defined, called] of [ + ['a-b', 'a_b'], + ['a_b', 'a_b'], + ]) { + assert.deepEqual( + own( + `$w: 650; @mixin ${defined} { font-weight: $w; } .x { @include ${called}; } $w: 500;` + ), + ['libs/h2/own.scss:1 650'] + ); + } + assert.deepEqual( + report([ + base, + user("@use 'base'; .x { @include base.heading_style($w: 650); }"), + ]), + ['libs/h2/user.scss:1 650'] + ); + assert.deepEqual( + report([ + base, + mod, + user("@use 'mod'; .x { @include mod.p_heading-style($w: 650); }"), + ]), + ['libs/h2/user.scss:1 650'] + ); +}); + +test('applies the show and hide lists of a @forward', { timeout: 5000 }, () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const a = scanWeights('libs/k2/_a.scss', '$w: 650;'); + const b = scanWeights('libs/k2/_b.scss', '$w: 600;'); + const mod = (body) => scanWeights('libs/k2/_mod.scss', body); + const user = (body) => scanWeights('libs/k2/user.scss', body); + const reads = user("@use 'mod'; .x { font-weight: mod.$w; }"); + const readsPrefixed = user("@use 'mod'; .x { font-weight: mod.$p-w; }"); + + assert.deepEqual( + report([a, b, mod("@forward 'a' hide $w; @forward 'b';"), reads]), + [] + ); + assert.deepEqual( + report([a, b, mod("@forward 'a' show $x; @forward 'b';"), reads]), + [] + ); + assert.deepEqual( + report([ + a, + b, + mod("@forward 'a' show $w; @forward 'b' hide $w;"), + reads, + ]), + ['libs/k2/_a.scss:1 650'] + ); + // The lists name members as the `@forward` exposes them. + assert.deepEqual( + report([a, mod("@forward 'a' as p-* hide $p-w;"), readsPrefixed]), + [] + ); + assert.deepEqual( + report([a, mod("@forward 'a' as p-* hide $w;"), readsPrefixed]), + ['libs/k2/_a.scss:1 650'] + ); + // A list further out names the prefixes added below it. + const inner = scanWeights('libs/k2/_inner.scss', "@forward 'a' as p-*;"); + assert.deepEqual( + report([ + a, + inner, + mod("@forward 'inner' as o-* hide $o-p-w;"), + user("@use 'mod'; .x { font-weight: mod.$o-p-w; }"), + ]), + [] + ); + // A list below a prefix is read under that prefix. + const hides = scanWeights( + 'libs/k2/_hides.scss', + "@forward 'a' as p-* hide $p-w;" + ); + assert.deepEqual( + report([ + a, + hides, + mod("@forward 'hides' as o-*;"), + user("@use 'mod'; .x { font-weight: mod.$o-p-w; }"), + ]), + [] + ); + // Forwarding cycles end, with lists and with prefixes. + for (const [back, forward, read] of [ + ["@forward 'mod' hide $x;", "@forward 'loop' show $w;", '$w'], + ["@forward 'mod' as l-*;", "@forward 'loop' as m-*;", '$m-w'], + ]) { + const loop = scanWeights('libs/k2/_loop.scss', `${back} @forward 'a';`); + assert.deepEqual( + report([ + a, + loop, + mod(forward), + user(`@use 'mod'; .x { font-weight: mod.${read}; }`), + ]), + ['libs/k2/_a.scss:1 650'] + ); + } + // An `@import`ed file brings in what it forwards. + assert.deepEqual( + report([ + a, + mod("@forward 'a' as p-*;"), + user("@import 'mod'; .x { font-weight: $p-w; }"), + ]), + ['libs/k2/_a.scss:1 650'] + ); +}); + +test('reads a prefixed configuration the way Sass names it', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const tokens = scanWeights('libs/q2/_tokens.scss', '$w: 500 !default;'); + const rule = scanWeights( + 'libs/q2/_rule.scss', + '$w: 500 !default; .r { font-weight: $w; }' + ); + const mod = (body) => scanWeights('libs/q2/_mod.scss', body); + const user = (body) => scanWeights('libs/q2/user.scss', body); + + // `with (…)` on a prefixed `@forward` names the member unprefixed. + assert.deepEqual( + report([ + tokens, + mod("@forward 'tokens' as p-* with ($w: 650);"), + user("@use 'mod'; .x { font-weight: mod.$p-w; }"), + ]), + ['libs/q2/_mod.scss:1 650'] + ); + // A loader of the forwarding module configures it under the prefix, + // even where the `@forward` hides it. + assert.deepEqual( + report([ + rule, + mod("@forward 'rule' as p-*;"), + user("@use 'mod' with ($p-w: 650);"), + ]), + ['libs/q2/user.scss:1 650'] + ); + assert.deepEqual( + report([ + rule, + mod("@forward 'rule' as p-* hide $p-w;"), + user("@use 'mod' with ($p-w: 650);"), + ]), + ['libs/q2/user.scss:1 650'] + ); + const outer = scanWeights('libs/q2/_outer.scss', "@forward 'mod' as o-*;"); + assert.deepEqual( + report([ + rule, + mod("@forward 'rule' as p-*;"), + outer, + user("@use 'outer' with ($o-p-w: 650);"), + ]), + ['libs/q2/user.scss:1 650'] + ); + const heavy = scanWeights('libs/q2/_heavy.scss', '$w: 650 !default;'); + assert.deepEqual( + report([ + heavy, + mod("@forward 'heavy' as p-*;"), + user("@use 'mod' with ($p-w: 600); .x { font-weight: mod.$p-w; }"), + ]), + [] + ); +}); + +test('keeps a module default that a `null` configuration leaves unset', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const tokens = scanWeights('libs/n2/_tokens.scss', '$w: 650 !default;'); + const user = (body) => scanWeights('libs/n2/u.scss', body); + + assert.deepEqual( + report([ + tokens, + user( + "@use 'tokens' with ($w: null); .x { font-weight: tokens.$w; }" + ), + ]), + ['libs/n2/_tokens.scss:1 650'] + ); + assert.deepEqual( + report([ + tokens, + user("@use 'tokens' as * with ($w: null); .x { font-weight: $w; }"), + ]), + ['libs/n2/_tokens.scss:1 650'] + ); +}); + +test('counts a parameter default only where a call leaves it out', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const heading = '@mixin heading($w: 650) { font-weight: $w; }'; + const own = (body) => + report([scanWeights('libs/d2/own.scss', `${heading} ${body}`)]); + + assert.deepEqual(own('.x { @include heading($w: 600); }'), []); + assert.deepEqual( + own( + '.x { @include heading($w: 600); } .y { content: "@include heading;"; }' + ), + [] + ); + for (const omitted of [ + '@include heading;', + '@include heading();', + '@include heading { color: red; }', + ]) { + assert.deepEqual( + own(`.x { @include heading($w: 600); } .y { ${omitted} }`), + ['libs/d2/own.scss:1 650'] + ); + } + // Nothing calls it here, so it may be called from elsewhere. + assert.deepEqual(own(''), ['libs/d2/own.scss:1 650']); + // A comment between `@mixin` and the name is still a signature. + assert.deepEqual( + report([ + scanWeights( + 'libs/d2/note.scss', + '@mixin /* a long note on the heading */ heading($w: 650) { font-weight: $w; } .x { @include heading($w: 600); }' + ), + ]), + [] + ); + assert.deepEqual( + report([ + scanWeights( + 'libs/d2/note.scss', + '@mixin /* a long note on the heading */ heading($w: 650) { font-weight: $w; }' + ), + ]), + ['libs/d2/note.scss:1 650'] + ); + assert.deepEqual( + report([ + scanWeights( + 'libs/d2/note.scss', + '$w: 650; @mixin /* a long note on the heading */ a-b() { font-weight: $w; } $w: 500; .x { @include a-b; }' + ), + ]), + [] + ); + // A module's call reaches its own mixin, not a loader's namesake. + const loaded = scanWeights( + 'libs/d2/_loaded.scss', + '@mixin heading($w: 600) { font-weight: $w; } .x { @include heading($w: 600); }' + ); + const loader = scanWeights( + 'libs/d2/loader.scss', + `@use 'loaded'; ${heading}` + ); + assert.deepEqual(report([loaded, loader]), ['libs/d2/loader.scss:1 650']); + + const a = scanWeights('libs/d2/_a.scss', heading); + const b = scanWeights( + 'libs/d2/_b.scss', + '@mixin heading($w: 500) { font-weight: $w; }' + ); + const mod = scanWeights('libs/d2/_mod.scss', "@forward 'a' as p-*;"); + const user = (body) => scanWeights('libs/d2/user.scss', body); + // A call that leaves the parameter out of another module's mixin does + // not use this one's default. + assert.deepEqual( + report([ + a, + b, + user( + "@use 'a'; @use 'b'; .x { @include a.heading($w: 600); @include b.heading; }" + ), + ]), + [] + ); + assert.deepEqual( + report([a, user("@use 'a'; .x { @include a.heading; }")]), + ['libs/d2/_a.scss:1 650'] + ); + assert.deepEqual( + report([ + a, + mod, + user("@use 'mod'; .x { @include mod.p-heading($w: 600); }"), + ]), + [] + ); +}); + +test('caps JetBrains Mono rules at the heaviest bundled Mono face', () => { + const source = [ + ".a { font-family: 'JetBrains Mono', monospace; font-weight: 700; }", + '.b { font: 600 12px/1 "JetBrains Mono", monospace; }', + ".c { font-family: ui-monospace, 'JetBrains Mono'; font-weight: bold; }", + ".d { font-weight: 500; font-family: 'JetBrains Mono'; }", + '.e { font-weight: 700; }', + ".f { font-family: 'JetBrains Mono'; &:hover, .child { font-weight: 600; } }", + ".g { font-family: 'JetBrains Mono'; &--wide { font-weight: 700; } }", + ".h { font-family: 'JetBrains Mono'; .x { font-family: Roboto; font-weight: 700; } }", + ".i { font-family: 'JetBrains Mono'; @media (min-width: 1px) { font-weight: 600; } }", + "@font-face { font-family: 'JetBrains Mono'; font-weight: 700; }", + ".j { font-family: 'JetBrains Mono'; font-weight: 650; }", + ".k { font-family: 'JetBrains Mono'; --title-weight: 600; }", + ".l { font-family: 'JetBrains Mono'; font-family: Roboto; font-weight: 700; }", + "@mixin m { font-weight: 700; } .n { font-family: 'JetBrains Mono'; @include m; }", + ".o { font-family: 'JetBrains Mono'; @include up { font-weight: 700; } }", + ".p { font-family: 'JetBrains Mono'; @mixin local { font-weight: 700; } }", + ".q { font-weight: 700; font: 400 12px 'JetBrains Mono'; }", + ".r { font-weight: 700 !important; font: 400 12px 'JetBrains Mono'; }", + ".s { font: 400 12px 'JetBrains Mono'; font-weight: 700; }", + ".t { font-weight: 700; font-weight: 500; font-family: 'JetBrains Mono'; }", + ".u { font-weight: 700; font: bogus; font-family: 'JetBrains Mono'; }", + ".v { font-weight: 700; font: 12px; font-family: 'JetBrains Mono'; }", + ".w { font-weight: 700; font: var(--f); font-family: 'JetBrains Mono'; }", + ".x2 { font-weight: 700; font: inherit; font-family: 'JetBrains Mono'; }", + ".y2 { font-weight: 700; font: 12px/1.4 sans-serif; font-family: 'JetBrains Mono'; }", + ".z2 { font-weight: 700; font: 12px / 1.4; font-family: 'JetBrains Mono'; }", + ".z3 { font: 700 12px 1.4; font-family: 'JetBrains Mono'; }", + ".z4 { font-family: 'JetBrains Mono'; font: 12px; font-weight: 700; }", + ].join('\n'); + + assert.equal(MONO_WEIGHT_CAP, 500); + assert.deepEqual(offScale('libs/m3/a.component.scss', source).sort(), [ + '1 font-weight: 700', + '11 font-weight: 650', + // A mixin's weight lands where it is included. + '14 font-weight: 700', + '15 font-weight: 700', + '18 font-weight: 700', + '19 font-weight: 700', + '2 font: 600', + '21 font-weight: 700', + '22 font-weight: 700', + '26 font-weight: 700', + '28 font-weight: 700', + '3 font-weight: bold', + '6 font-weight: 600', + '9 font-weight: 600', + ]); + for (const finding of findOffScaleWeights( + 'libs/m3/a.component.scss', + source + ).findings) { + assert.match(describeFinding(finding), /JetBrains Mono.*Use 500\.$/); + } +}); + +test('follows the JetBrains Mono family only where it is inherited', () => { + const mono = "font-family: 'JetBrains Mono'"; + const source = [ + `.a { ${mono}; + .item { font-weight: 700; } }`, + `.b { ${mono}; & ~ .item { font-weight: 700; } }`, + `.c { ${mono}; &--wide, &:hover { font-weight: 700; } }`, + `.d { ${mono}; :is(.x, .y) { font-weight: 700; } }`, + `.e { ${mono}; .f { font-family: inherit; font-weight: 700; } }`, + `.g { ${mono}; + .h { font-family: inherit; font-weight: 700; } }`, + `.i { ${mono}; .u { font-family: Roboto; font-family: unset; font-weight: 700; } }`, + `.j { ${mono}; .k { font-family: Roboto; font-family: inherit; font-weight: 700; } }`, + `.l { ${mono}; &--wide:is(.x, .y) { font-weight: 700; } }`, + `.m { ${mono} !important; font-family: sans-serif; font-weight: 700; }`, + `.n { font-family: Roboto !important; ${mono}; font-weight: 700; }`, + `.o { font-family: Roboto !important; ${mono} !important; font-weight: 700; }`, + ].join('\n'); + + assert.deepEqual(offScale('libs/m4/a.scss', source), [ + '3 font-weight: 700', + '4 font-weight: 700', + '5 font-weight: 700', + '7 font-weight: 700', + '8 font-weight: 700', + '10 font-weight: 700', + '12 font-weight: 700', + ]); +}); + +test('resolves a JetBrains Mono family named through variables', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const rule = (body) => scanWeights('libs/m4/rule.scss', body); + const theme = (body) => scanWeights('libs/m4/theme.scss', body); + + assert.deepEqual( + report([ + theme( + ':root { --face: ui-monospace, "JetBrains Mono", monospace; }' + ), + rule('.a { font-family: var(--face); font-weight: 600; }'), + ]), + ['libs/m4/rule.scss:1 600'] + ); + assert.deepEqual( + report([ + theme(":root { --face: var(--brand); --brand: 'JetBrains Mono'; }"), + rule('.a { font: 700 12px var(--face); }'), + ]), + ['libs/m4/rule.scss:1 700'] + ); + assert.deepEqual( + report([ + theme(':root { --face: Roboto, sans-serif; }'), + rule('.a { font-family: var(--face); font-weight: 700; }'), + ]), + [] + ); + // A `var()` fallback counts only where the property is never set. + const fallback = (family) => + rule(`.a { font-family: ${family}; font-weight: 700; }`); + const everything = (scans) => [ + ...scans.flatMap((scan) => + scan.findings.map( + ({ file, line, value }) => `${file}:${line} ${value}` + ) + ), + ...report(scans), + ]; + for (const family of [ + "var(--face, 'JetBrains Mono')", + "var(--face, var(--y), 'JetBrains Mono')", + ]) { + assert.deepEqual( + everything([theme(':root { --face: Roboto; }'), fallback(family)]), + [] + ); + } + for (const family of [ + "var(--nowhere, ui-monospace, 'JetBrains Mono', monospace)", + "var(--x, var(--y, 'JetBrains Mono'))", + ]) { + assert.deepEqual(report([fallback(family)]), [ + 'libs/m4/rule.scss:1 700', + ]); + } + assert.deepEqual( + report([ + theme(':root { --y: Roboto; }'), + fallback("var(--x, var(--y, 'JetBrains Mono'))"), + ]), + [] + ); + assert.deepEqual( + report([ + theme(":root { --face: var(--brand, 'JetBrains Mono'); }"), + fallback('var(--face)'), + ]), + ['libs/m4/rule.scss:1 700'] + ); + // A property set to a CSS-wide keyword, or through an unset `var()` + // without a fallback, can be unset too. + for (const face of ['initial', 'inherit', 'var(--nope)']) { + assert.deepEqual( + report([ + theme(`:root { --face: ${face}; }`), + fallback("var(--face, 'JetBrains Mono')"), + ]), + ['libs/m4/rule.scss:1 700'] + ); + } + assert.deepEqual( + report([ + theme(':root { --face: var(--nope, Roboto); }'), + fallback("var(--face, 'JetBrains Mono')"), + ]), + [] + ); + // `inherit` takes what another rule sets; a fallback that is itself + // unset leaves the value invalid. + assert.deepEqual( + report([ + theme(':root { --face: Roboto; } .child { --face: inherit; }'), + fallback("var(--face, 'JetBrains Mono')"), + ]), + [] + ); + assert.deepEqual( + report([ + theme(':root { --face: var(--missing, var(--also-missing)); }'), + fallback("var(--face, 'JetBrains Mono')"), + ]), + ['libs/m4/rule.scss:1 700'] + ); + assert.deepEqual( + report([ + rule( + "$mono: 'JetBrains Mono', monospace; .a { font-family: $mono; font-weight: 600; }" + ), + ]), + ['libs/m4/rule.scss:1 600'] + ); + assert.deepEqual( + report([ + scanWeights('libs/m4/_type.scss', "$mono: 'JetBrains Mono';"), + rule("@use 'type'; .a { font: 600 12px type.$mono; }"), + ]), + ['libs/m4/rule.scss:1 600'] + ); + // Its weights through variables are capped too, an off-scale one is + // reported once, and a family set from code counts. + assert.deepEqual( + report([ + theme(":root { --face: 'JetBrains Mono'; }"), + rule( + '$w: 600; .a { font-family: var(--face); font-weight: $w; } .b { font-family: var(--face); font-weight: 650; }' + ), + ]), + ['libs/m4/rule.scss:1 600'] + ); + assert.deepEqual( + report([ + scanWeights( + 'libs/m4/x.component.ts', + "el.style.setProperty('--face', \"'JetBrains Mono'\");" + ), + rule('.a { font-family: var(--face); font-weight: 700; }'), + ]), + ['libs/m4/rule.scss:1 700'] + ); + // A property in a cycle is invalid, so its readers take the fallback; + // one read twice in a value is no cycle. + const mono = rule( + ".a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }" + ); + for (const cycle of [ + ':root { --face: var(--face); }', + ':root { --face: var(--other); --other: var(--face); }', + ]) { + assert.deepEqual( + report([theme(cycle), mono]), + ['libs/m4/rule.scss:1 700'], + cycle + ); + } + assert.deepEqual( + report([ + theme( + ':root { --base: Roboto; --face: var(--base), var(--base); }' + ), + mono, + ]), + [] + ); + // The same selector written again in the file sets it on the same + // elements; another selector, a condition or another file does not. + const read = + ".a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }"; + for (const [setter, expected] of [ + ['.a { --face: Roboto; }', []], + ['.b { --face: Roboto; }', ['libs/m4/rule.scss:1 700']], + [ + '@media (min-width: 1px) { .a { --face: Roboto; } }', + ['libs/m4/rule.scss:1 700'], + ], + ['.p { .a { --face: Roboto; } }', ['libs/m4/rule.scss:1 700']], + ]) { + assert.deepEqual(report([rule(`${setter} ${read}`)]), expected, setter); + } + // Nested alike, or written with other spacing, it is the same selector. + for (const same of [ + ".p { .a { --face: Roboto; } } .p { .a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; } }", + ".p .a { --face: Roboto; } .p .a { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }", + ]) { + assert.deepEqual(report([rule(same)]), [], same); + } + // A string's spacing is its own, past an escaped quote too: these match + // different elements. + for (const [setter, reader] of [ + ["[title='a b']", "[title='a b']"], + ["[title='a \\'b']", "[title='a \\'b']"], + ]) { + assert.deepEqual( + report([ + rule( + `${setter} { --face: Roboto; } ${reader} { font-family: var(--face, 'JetBrains Mono'); font-weight: 700; }` + ), + ]), + ['libs/m4/rule.scss:1 700'], + setter + ); + } + assert.deepEqual(report([theme('.a { --face: Roboto; }'), rule(read)]), [ + 'libs/m4/rule.scss:1 700', + ]); +}); + +test('reads `!important` however it is spaced or cased', () => { + const mono = "'JetBrains Mono'"; + const report = (file, body) => + findOffScaleWeights(file, body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [ + `.x { font-family: ${mono} ! important; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono} !/* why */important; font-weight: 700; }`, + ['font-weight: 700'], + ], + [`.x { font: 700 12px ${mono} ! IMPORTANT; }`, ['font: 700']], + // It wins over a later declaration of its property. + [ + `.x { font-family: ${mono} ! important; font-family: Roboto; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-weight: 700 ! important; font-family: ${mono}; font-weight: 400; }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono} ! important; } :root { --face: Roboto; } .x { font-family: var(--face); font-weight: 700; }`, + ['font-weight: 700'], + ], + // A shorthand keyword with it still parses, so it sets the family. + [ + `.x { font-family: ${mono}; font: inherit ! important; font-weight: 700; }`, + [], + ], + ]) { + assert.deepEqual(report('libs/s2/a.scss', source), expected, source); + } + assert.deepEqual( + report( + 'apps/web/src/a.component.html', + `
` + ), + ['font-weight: 700'] + ); + // A shorthand that is all variables keeps its family from them. + assert.deepEqual( + findIndirectWeights([ + scanWeights( + 'libs/s2/theme.scss', + `:root { --f: 400 12px ${mono}; }` + ), + scanWeights( + 'libs/s2/b.scss', + '.x { font: var(--f) ! important; .y { font-weight: 700; } }' + ), + ]).map(({ name, value }) => `${name}: ${value}`), + ['font-weight: 700'] + ); +}); + +test('treats blocks with the same selector as one rule', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s1/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // Its family is the last one in source order, wherever the weight sits, + // unless an earlier one is `!important`. + for (const [source, expected] of [ + [ + `.x { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-weight: 700; } .x { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .x { font-family: Roboto; } .x { font-weight: 700; }`, + [], + ], + [ + `.x { font-family: ${mono} !important; } .x { font-family: Roboto; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.p { .x { font-family: ${mono}; } } .p { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@media (min-width: 1px) { .x { font-family: ${mono}; } } @media (min-width: 1px) { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + // Another selector is another rule. + [`.y { font-family: ${mono}; } .x { font-weight: 700; }`, []], + // A later weight in the rule replaces an earlier one. + [ + `.x { font-family: ${mono}; font-weight: 700; } .x { font-weight: 400; }`, + [], + ], + // So does a custom property, unless an `@if` may skip the later one. + [ + `:root { --face: ${mono}; } :root { --face: Roboto; } .x { font-family: var(--face); font-weight: 700; }`, + [], + ], + [ + `:root { --face: Roboto; } :root { --face: ${mono}; } .x { font-family: var(--face); font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono} !important; } :root { --face: Roboto; } .x { font-family: var(--face); font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } @if $a { :root { --face: Roboto; } } .x { font-family: var(--face); font-weight: 700; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } + // An interpolated selector reads the literal its variable holds there, + // so it is the same rule where the value is and another where it is not. + const weight = `font-weight: 700;`; + for (const [source, expected] of [ + [ + `$n: a; .#{$n} { font-family: ${mono}; ${weight} } $n: b; .#{$n} { font-family: Roboto; }`, + ['font-weight: 700'], + ], + [ + `$n: a; .#{$n} { font-family: Roboto; ${weight} } $n: b; .#{$n} { font-family: ${mono}; }`, + [], + ], + [ + `$n: a; .#{$n} { font-family: ${mono}; ${weight} } .#{$n} { font-weight: 500; }`, + [], + ], + [ + `$n: a; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `$n: 'a'; .#{$n} { ${weight} } .a { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `.p { $n: a; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `$n: a; .#{$n} { ${weight} } @include m($n: b); .#{$n} { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + // A string or a mixin's local assignment is not another value. + [ + `$n: a; .#{$n} { ${weight} } @debug "$n: b"; .#{$n} { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `@mixin m { $n: a; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `$n: a; .#{$n} { --face: Roboto; } .#{$n} { font-family: var(--face, ${mono}); ${weight} }`, + [], + ], + [ + `$n: a; .#{$n} { --face: Roboto; } $n: b; .#{$n} { font-family: var(--face, ${mono}); ${weight} }`, + ['font-weight: 700'], + ], + // A value the scan cannot know keeps each block its own rule. + [`.#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, []], + [ + `$n: a; @if $c { $n: b; } .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, + [], + ], + [ + `$n: a; .y { $n: b !global; } .#{$n} { ${weight} } .a { font-family: ${mono}; }`, + [], + ], + [ + `$n: a !default; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, + [], + ], + [ + `.x { $n: a; } .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, + [], + ], + [`$n: t.$v; .#{$n} { ${weight} } .#{$n} { font-family: ${mono}; }`, []], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test("prefers a rule's own custom property over an inherited one", () => { + const mono = "'JetBrains Mono'"; + const read = 'font-family: var(--face); font-weight: 700;'; + const report = (body) => + findOffScaleWeights('libs/s3/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [`:root { --face: ${mono}; } .x { --face: Roboto; ${read} }`, []], + [ + `:root { --face: Roboto; } .x { --face: ${mono}; ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } .x { --face: Roboto; } .x { ${read} }`, + [], + ], + [`.p { --face: ${mono}; .x { --face: Roboto; ${read} } }`, []], + // Not its own: inherited on purpose, conditional, or another rule. + [ + `:root { --face: ${mono}; } .x { --face: inherit; ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } .x { @if $a { --face: Roboto; } ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } @media (min-width: 1px) { .x { --face: Roboto; } } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } .y { --face: Roboto; } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } @if $a { .x { --face: Roboto; } } .x { ${read} }`, + ['font-weight: 700'], + ], + // Inside the same condition, it is its own. + [ + `:root { --face: ${mono}; } .x { @if $a { --face: Roboto; ${read} } }`, + [], + ], + // Sass variables keep Sass scoping: an `@if` may still assign Mono. + [ + `.x { $f: Roboto; @if $a { $f: ${mono}; } font-family: $f; font-weight: 700; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } + // Another file's rule styles other elements (component styles). + assert.deepEqual( + findIndirectWeights([ + scanWeights('libs/s3/theme.scss', `:root { --face: ${mono}; }`), + scanWeights('libs/s3/b.scss', '.x { --face: Roboto; }'), + scanWeights('libs/s3/a.scss', `.x { ${read} }`), + ]).map(({ value }) => value), + ['700'] + ); + // A value set from code (an inline style) can still win. + assert.deepEqual( + findIndirectWeights([ + scanWeights( + 'libs/s3/x.component.ts', + `el.style.setProperty('--face', "${mono}");` + ), + scanWeights('libs/s3/a.scss', `.x { --face: Roboto; ${read} }`), + ]).map(({ value }) => value), + ['700'] + ); +}); + +test('prefers the nearest inherited custom property', () => { + const mono = "'JetBrains Mono'"; + const read = 'font-family: var(--face); font-weight: 700;'; + const report = (body) => + findOffScaleWeights('libs/s4/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // `*` sets each element, an enclosing rule sits nearer than `body`, + // `body` nearer than `:root`, and a registered initial value applies + // only where nothing is set. + for (const [source, expected] of [ + [ + `:root { --face: ${mono}; } body { --face: Roboto; } .x { ${read} }`, + [], + ], + [ + `:root { --face: Roboto; } body { --face: ${mono}; } .x { ${read} }`, + ['font-weight: 700'], + ], + [`:root { --face: ${mono}; } * { --face: Roboto; } .x { ${read} }`, []], + [ + `* { --face: ${mono}; } body { --face: Roboto; } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } .p { --face: Roboto; .x { ${read} } }`, + [], + ], + [`.p { --face: ${mono}; .q { --face: Roboto; .x { ${read} } } }`, []], + [ + `.p { --face: Roboto; .q { --face: ${mono}; .x { ${read} } } }`, + ['font-weight: 700'], + ], + [ + `@property --face { syntax: '*'; inherits: true; initial-value: ${mono}; } body { --face: Roboto; } .x { ${read} }`, + [], + ], + // Not settled: inherited on purpose, conditional, or not an + // ancestor for sure; and `body` does not reach `:root`. + [ + `:root { --face: ${mono}; } body { --face: inherit; } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } @media (min-width: 1px) { body { --face: Roboto; } } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } .y { --face: Roboto; } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } :root { ${read} }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } + // Across files too; a value set from code can still win. + const theme = (body) => scanWeights('libs/s4/theme.scss', body); + const consumer = scanWeights('libs/s4/b.scss', `.x { ${read} }`); + // A condition in another file is not the reader's, even at the same + // place in its text. + assert.deepEqual( + findIndirectWeights([ + theme(`:root { --face: ${mono}; }`), + scanWeights( + 'libs/s4/d.scss', + '@media (min-width: 1px) { body { --face: Roboto; } }' + ), + scanWeights( + 'libs/s4/e.scss', + `@media (min-width: 1px) { .x { ${read} } }` + ), + ]).map(({ value }) => value), + ['700'] + ); + assert.deepEqual( + findIndirectWeights([ + theme(`:root { --face: ${mono}; } body { --face: Roboto; }`), + consumer, + ]), + [] + ); + assert.deepEqual( + findIndirectWeights([ + theme('body { --face: Roboto; }'), + scanWeights( + 'libs/s4/x.component.ts', + `el.style.setProperty('--face', "${mono}");` + ), + consumer, + ]).map(({ value }) => value), + ['700'] + ); +}); + +test('reads an interpolated family declaration whole', () => { + const report = (body) => + findOffScaleWeights('libs/s4/c.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [ + "$face: 'JetBrains Mono'; .x { font-family: #{$face}; font-weight: 700; }", + ['font-weight: 700'], + ], + [ + "$face: 'JetBrains Mono'; .x { font-family: #{$face}, monospace; font-weight: 700; }", + ['font-weight: 700'], + ], + [ + "$face: 'JetBrains Mono'; .x { font-family: #{$face}; .y { font-weight: 700; } }", + ['font-weight: 700'], + ], + ['$face: Roboto; .x { font-family: #{$face}; font-weight: 700; }', []], + // The list goes on past the interpolation, and past a quoted `;`. + [ + "$face: 'Fira'; .x { font-family: #{$face}, 'JetBrains Mono'; font-weight: 700; }", + ['font-weight: 700'], + ], + [ + ".x { font-family: 'Odd;Name', 'JetBrains Mono'; font-weight: 700; }", + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads a selector list as each of its selectors', () => { + const mono = "'JetBrains Mono'"; + const read = 'font-family: var(--face); font-weight: 700;'; + const report = (body) => + findOffScaleWeights('libs/s5/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // A family set for one selector of a list reaches that selector. + [ + `.x, .y { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .x, .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.y { font-family: ${mono}; } .x, .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a, .b { .x { font-family: ${mono}; } } .a { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x, .y { font-family: ${mono}; } .x { font-family: Roboto; } .x { font-weight: 700; }`, + [], + ], + [`.x, .y { font-family: ${mono}; } .z { font-weight: 700; }`, []], + // A weight stays in effect while it is for any of its selectors. + [ + `.x, .y { font-weight: 700; } .x { font-weight: 400; } .x, .y { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `.x, .y { font-weight: 700; } .x, .y { font-weight: 400; } .x { font-family: ${mono}; }`, + [], + ], + // A custom property: set for the reader's selectors, replaced for them. + [ + `:root { --face: ${mono}; } .x, .y { --face: Roboto; } .x { ${read} }`, + [], + ], + [ + `:root { --face: ${mono}; } .x { --face: Roboto; } .x, .y { ${read} }`, + ['font-weight: 700'], + ], + [ + `.x, .y { --face: ${mono}; } .x { --face: Roboto; } .x { ${read} }`, + [], + ], + [ + `.x, .y { --face: ${mono}; } .x { --face: Roboto; } .y { ${read} }`, + ['font-weight: 700'], + ], + // Selectors without a family of their own inherit one from outside; + // one named through a variable is resolved later. + [ + `.p { font-family: ${mono}; .x { font-family: Roboto; } .x, .y { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `:root { --f: ${mono}; } .x { font-family: var(--f); } .x, .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + // More than 16 chains keep the block its own rule. + [ + `.a, .b, .c, .d, .e { .f, .g, .h, .i { font-family: ${mono}; } } .a { .f { font-weight: 700; } }`, + [], + ], + // A comma in a string is no list; `:is(.x, .y)` is one selector + // that every `.x` matches. + [ + `[title="a, b"] { font-family: ${mono}; } [title="a"] { font-weight: 700; }`, + [], + ], + [ + `:is(.x, .y) { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads `revert` on a custom property as inheriting', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s5/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // `revert` and `revert-layer` bring back the inherited value; only + // `initial` leaves the property unset, for the fallback. + for (const [keyword, expected] of [ + ['revert', ['font-weight: 700']], + ['revert-layer', ['font-weight: 700']], + ['unset', ['font-weight: 700']], + ['initial', []], + ]) { + assert.deepEqual( + report( + `:root { --face: ${mono}; } .x { --face: ${keyword}; font-family: var(--face, Roboto); font-weight: 700; }` + ), + expected, + keyword + ); + } + assert.deepEqual( + report( + `.p { font-family: ${mono}; .x { font-family: revert; font-weight: 700; } }` + ), + ['font-weight: 700'] + ); +}); + +test("reads a mixin's declarations where it is included", () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s6/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // Its family, at the `@include`, in source order with the rule's own. + [ + `@mixin mono { font-family: ${mono}; } .x { @include mono; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@mixin mono { font-family: ${mono}; } .x { @include mono; font-family: Roboto; font-weight: 700; }`, + [], + ], + [ + `@mixin mono { font-family: ${mono}; } .x { font-family: Roboto; @include mono; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@mixin mono { font-family: ${mono}; } .x { @include mono; .y { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@mixin m-o { font-family: ${mono}; } .x { @include m_o; font-weight: 700; }`, + ['font-weight: 700'], + ], + [`@mixin mono { font-family: ${mono}; } .x { font-weight: 700; }`, []], + // Its weight, against the including rule's family unless it sets one. + [ + `@mixin heavy { font-weight: 700; } .x { font-family: Roboto; @include heavy; }`, + [], + ], + [ + `@mixin heavy { font-family: Roboto; font-weight: 700; } .x { font-family: ${mono}; @include heavy; }`, + [], + ], + // A family named through a variable, resolved later. + [ + `@mixin heavy { font-weight: 700; } :root { --f: ${mono}; } .x { font-family: var(--f); @include heavy; }`, + ['font-weight: 700'], + ], + // Another module's mixin, or the text in a string, is not this one. + [ + `@mixin t { font-family: ${mono}; } .x { @include t.other; font-weight: 700; }`, + [], + ], + [ + `@mixin mono { font-family: ${mono}; } .x { content: "@include mono"; font-weight: 700; }`, + [], + ], + // A variable from outside may change before the `@include`. + [ + `$n: a; @mixin m { .#{$n} { font-family: ${mono}; font-weight: 700; } .a { font-weight: 500; } } $n: b; .x { @include m; }`, + ['font-weight: 700'], + ], + [ + `$n: a; @mixin m { .#{$n} { font-family: ${mono}; font-weight: 700; } } $n: b; .a { font-weight: 500; } .b { @include m; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads a self-sibling selector as the parent element', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s6/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [ + `.x { font-family: ${mono}; & + & { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; & ~ &.on { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [`.x { font-family: ${mono}; & + .y { font-weight: 700; } }`, []], + [`.x { font-family: ${mono}; &-y { font-weight: 700; } }`, []], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('inherits a family from a flat ancestor selector', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s7/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // The nearest top-level rule naming an ancestor of the target gives its + // family; a sibling is no ancestor. + for (const [source, expected] of [ + [ + `.parent { font-family: ${mono}; } .parent .child { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent { font-family: ${mono}; } .parent > .child { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a { font-family: ${mono}; } .a>.b { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a { font-family: ${mono}; } .a .b + .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a { font-family: ${mono}; } @media (min-width: 1px) { .a .b { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.a { font-family: Roboto; } .a .b { font-family: ${mono}; } .a .b .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a { font-family: ${mono}; } .a .b { font-family: inherit; } .a .b .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent { font-family: ${mono}; } .parent + .child { font-weight: 700; }`, + [], + ], + [ + `.a { font-family: ${mono}; } .a .b { font-family: Roboto; } .a .b .c { font-weight: 700; }`, + [], + ], + [ + `.a { font-family: ${mono}; } .a .b { font-family: Roboto; font-weight: 700; }`, + [], + ], + // Spaced one way: `.a>.b` names the same element as `.a > .b`, and + // `.a.b` (one element with both classes) is no ancestor of `.a .b`. + [ + `.a>.b { font-family: ${mono}; } .a > .b .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [`.a.b { font-family: ${mono}; } .a .b .c { font-weight: 700; }`, []], + // In a selector list, any selector whose ancestor renders Mono counts. + [ + `.p { font-family: Roboto; } .q { font-family: ${mono}; } .p .x, .q .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:root { --f: ${mono}; } .p { font-family: Roboto; } .q { font-family: var(--f); } .p .x, .q .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads a non-inheriting registered property and a compound root', () => { + const mono = "'JetBrains Mono'"; + const read = 'font-family: var(--face); font-weight: 700;'; + const registered = (initial, inherits) => + `@property --face { syntax: '*'; inherits: ${inherits}; initial-value: ${initial}; }`; + const report = (body) => + findOffScaleWeights('libs/s7/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // `inherits: false`: no ancestor's value reaches the element. + [ + `${registered(mono, 'false')} body { --face: Roboto; } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `${registered('Roboto', 'false')} body { --face: ${mono}; } .x { ${read} }`, + [], + ], + [ + `${registered('Roboto', 'false')} * { --face: ${mono}; } .x { ${read} }`, + ['font-weight: 700'], + ], + [ + `${registered('Roboto', 'false')} .x { --face: ${mono}; ${read} }`, + ['font-weight: 700'], + ], + [ + `${registered(mono, 'true')} body { --face: Roboto; } .x { ${read} }`, + [], + ], + // A compound selector on the root element is beyond `body`'s reach, + // past brackets, strings, parentheses and escapes in it. + [ + `:root { --face: ${mono}; } body { --face: Roboto; } html:not(.a .b) { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } html[data-x="a] b"] { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } html.a\\ b { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } html[data-x="a"] .x { ${read} }`, + [], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } html.theme { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } :root:not(.x) { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: ${mono}; } body { --face: Roboto; } html .x { ${read} }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads a Sass `@extend` as the extended rule applied to its own', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s8/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // The extended rule's family, where that rule is written. + [ + `%mono { font-family: ${mono}; } .x { @extend %mono; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.m { font-family: ${mono}; } .x { @extend .m; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `%mono { font-family: ${mono}; } .x { @extend %mono !optional; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `%mono { font-family: ${mono}; } .x { @extend %mono; .y { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: Roboto; @extend %mono; font-weight: 700; } %mono { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `%mono { font-family: ${mono}; } .x { font-family: Roboto; @extend %mono; font-weight: 700; }`, + [], + ], + [ + `%mono { font-family: ${mono}; } .x { @extend %other; font-weight: 700; }`, + [], + ], + [ + `%mono { font-family: ${mono}; } .x { content: "@extend %mono;"; font-weight: 700; }`, + [], + ], + [ + `.m { font-family: ${mono}; } .x { @extend .n, .m; font-weight: 700; }`, + ['font-weight: 700'], + ], + // Its weight, against each extending rule's family. + [ + `%heavy { font-weight: 700; } .x { font-family: ${mono}; @extend %heavy; }`, + ['font-weight: 700'], + ], + [ + `%heavy { font-weight: 700; } .x { font-family: Roboto; @extend %heavy; }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads custom properties per selector of a reading list', () => { + const mono = "'JetBrains Mono'"; + const read = 'font-family: var(--face); font-weight: 700;'; + const report = (body) => + findOffScaleWeights('libs/s8/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // Each selector of `.x, .y` styles its own elements: an override for + // one, or no value of its own there, decides that selector alone. + for (const [source, expected] of [ + [ + `.x, .y { --face: Roboto; } .y { --face: ${mono}; } .x, .y { ${read} }`, + ['font-weight: 700'], + ], + [ + `.x, .y { --face: ${mono}; } .y { --face: Roboto; } .x, .y { ${read} }`, + ['font-weight: 700'], + ], + [ + `.x, .y { --face: ${mono}; } .x { --face: Roboto; } .y { --face: Roboto; } .x, .y { ${read} }`, + [], + ], + [ + `:root { --face: ${mono}; } .x { --face: Roboto; } .x, .y { ${read} }`, + ['font-weight: 700'], + ], + [ + `:root { --face: Roboto; } .x { --face: Roboto; } .x, .y { ${read} }`, + [], + ], + [ + `:root { --face: ${mono}; } .x, .y { --face: Roboto; } .x, .y { ${read} }`, + [], + ], + // Every selector setting its own leaves no room for another rule's, + // and one replaced for a selector is gone there. + [ + `.z { --face: ${mono}; } .x { --face: Roboto; } .y { --face: Roboto; } .x, .y { ${read} }`, + [], + ], + [ + `.x, .z { --face: ${mono}; } .x { --face: Roboto; } .y { --face: Roboto; } .x, .y { ${read} }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } + // A value set from code reads others through `var()`, with no selector. + assert.deepEqual( + findIndirectWeights([ + scanWeights( + 'libs/s8/x.component.ts', + "el.style.setProperty('--face', 'var(--brand)');" + ), + scanWeights('libs/s8/theme.scss', `:root { --brand: ${mono}; }`), + scanWeights('libs/s8/c.scss', `.x { ${read} }`), + ]).map(({ value }) => value), + ['700'] + ); +}); + +test('reads a compound selector with the rules it contains', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s9/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // An element of `.x:hover` (or `.x.on`, `a.x`, `.p .x:hover`) is an `.x`. + for (const [source, expected] of [ + [ + `.x { font-family: ${mono}; } .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .x.on { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } a.x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .p .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .x::before { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .x:hover, .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.md\\:x { font-family: ${mono}; } .md\\:x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } @media (min-width: 1px) { .x:hover { font-weight: 700; } }`, + ['font-weight: 700'], + ], + // The rule's own family wins, a base's beats an inherited one, and a + // compound that only looks alike is another element. + [ + `.x { font-family: ${mono}; } .x:hover { font-family: Roboto; font-weight: 700; }`, + [], + ], + [ + `.x { font-family: Roboto; } .p { font-family: ${mono}; .x:hover { font-weight: 700; } }`, + [], + ], + [`.x { font-family: ${mono}; } .xy:hover { font-weight: 700; }`, []], + [`.x { font-family: ${mono}; } .y:not(.x) { font-weight: 700; }`, []], + [`.x.on { font-family: ${mono}; } .x:hover { font-weight: 700; }`, []], + [`.x { font-family: ${mono}; } .x#{$s} { font-weight: 700; }`, []], + // A base that inherits, or covers only some selectors, leaves the + // rest to the parent. + [ + `.x { font-family: inherit; } .p { font-family: ${mono}; .x:hover { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: Roboto; } .p { font-family: ${mono}; .x:hover, .y { font-weight: 700; } }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads quoted family text as a name', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s9/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // In a string only a Sass `#{…}` is a variable. + for (const [source, expected] of [ + [ + `:root { --face: Roboto; } .x { font-family: "var(--face)", ${mono}; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `$face: Roboto; .x { font-family: "$face", ${mono}; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `$face: Roboto; .x { font-family: "#{$face}", ${mono}; font-weight: 700; }`, + [], + ], + [ + `:root { --face: Roboto; } .x { font-family: var(--face, "a)b"), ${mono}; font-weight: 700; }`, + [], + ], + // A quoted `)` stays in the fallback, and a quoted `var()` in a + // value is no reference that could leave it invalid. + [ + `.x { font-family: var(--nope, "a)b", Roboto), ${mono}; font-weight: 700; }`, + [], + ], + [ + `:root { --face: "var(--x)", Roboto; } .x { font-family: var(--face, ${mono}); font-weight: 700; }`, + [], + ], + [ + `:root { --face: "a\\"var(--x)", Roboto; } .x { font-family: var(--face, ${mono}); font-weight: 700; }`, + [], + ], + [ + `.x { font-family: "a\\"var(--face)", ${mono}; font-weight: 700; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads mixin weights where they land, through nested includes', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s10/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // A mixin's weight meets the including rule's family, a later one + // of its own included. + [ + `@mixin m { font-family: ${mono}; font-weight: 700; } .x { @include m; font-family: Roboto; }`, + [], + ], + [ + `@mixin m { font-family: Roboto; font-weight: 700; } .x { @include m; font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `@mixin m { font-family: ${mono}; font-weight: 700; } .x { @include m; }`, + ['font-weight: 700'], + ], + // Not included here: it is read where it is written. + [ + `@mixin m { font-family: ${mono}; font-weight: 700; }`, + ['font-weight: 700'], + ], + // A mixin included in another goes on to that one's includes. + [ + `@mixin inner { font-family: ${mono}; } @mixin outer { @include inner; } .x { @include outer; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@mixin inner { font-weight: 700; } @mixin outer { @include inner; } .x { font-family: ${mono}; @include outer; }`, + ['font-weight: 700'], + ], + [ + `@mixin a { @include b; } @mixin b { @include a; font-weight: 700; } .x { font-family: ${mono}; @include a; }`, + ['font-weight: 700'], + ], + [ + `@mixin a { @include b; } @mixin b { @include a; font-family: ${mono}; } .x { @include a; font-weight: 700; }`, + ['font-weight: 700'], + ], + // Included in several rules: any whose family renders Mono counts. + [ + `@mixin h { font-weight: 700; } .a { font-family: Roboto; @include h; } .b { font-family: ${mono}; @include h; }`, + ['font-weight: 700'], + ], + [ + `:root { --f: ${mono}; } @mixin h { font-weight: 700; } .a { font-family: Roboto; @include h; } .b { font-family: var(--f); @include h; }`, + ['font-weight: 700'], + ], + // Each include is a landing of its own: a later weight replaces it, + // and an include after that replaces that weight in turn. + [ + `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; font-weight: 500; }`, + [], + ], + [ + `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; font-weight: 500; @include m; }`, + ['font-weight: 700'], + ], + [ + `@mixin m { font-weight: 500; } .x { font-family: ${mono}; @include m; font-weight: 700; @include m; }`, + [], + ], + [ + `@mixin m { font-weight: 700 !important; } .x { font-family: ${mono}; @include m; font-weight: 500; }`, + ['font-weight: 700'], + ], + // Where it is replaced, its family is not the one it renders in. + [ + `@mixin m { font-weight: 700; } .a { font-family: ${mono}; @include m; font-weight: 500; } .b { font-family: Roboto; @include m; }`, + [], + ], + [ + `@mixin m { font-weight: 700; } .a { font-family: Roboto; @include m; font-weight: 500; } .b { font-family: ${mono}; @include m; }`, + ['font-weight: 700'], + ], + [ + `@mixin m { font: { weight: 700; } } .x { font-family: ${mono}; @include m; }`, + ['weight: 700'], + ], + // A content block is the including rule's where the mixin places + // `@content` at its top level, in order there. + [ + `@mixin w { font-weight: 700; @content; } .x { @include w { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { font-family: ${mono}; @content; } .x { @include w { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { font-weight: 700; @content; } .x { font-family: Roboto; @include w { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { font-weight: 700; @content; } .x { @include w { font-family: ${mono}; } font-family: Roboto; }`, + [], + ], + [ + `@mixin w { @content; font-family: Roboto; } .x { font-weight: 700; @include w { font-family: ${mono}; } }`, + [], + ], + [ + `@mixin w { @content; font-weight: 500; } .x { font-family: ${mono}; @include w { font-weight: 700; } }`, + [], + ], + [ + `@mixin w { font-weight: 500; @content; } .x { font-family: ${mono}; @include w { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { @content; } .x { font-family: ${mono}; @include w { font-weight: 700; } font-weight: 500; }`, + [], + ], + // Sass conditions are not evaluated: any branch may run. + [ + `@mixin w { @if false { @content; } } .x { font-weight: 700; @include w { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { @if true { @content; } } .x { font-weight: 700; @include w { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { font-weight: 700; @content; } @mixin outer { @include w { font-family: ${mono}; } } .x { @include outer; }`, + ['font-weight: 700'], + ], + [ + `@mixin w { @content; } .a { @include w { font-family: ${mono}; } } .b { font-weight: 700; @include w { font-family: Roboto; } }`, + [], + ], + [ + `@mixin w { @content; font-weight: 500; @content; } .x { font-family: ${mono}; @include w { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@mixin w { @content; } .a { font-family: ${mono}; @include w { font-weight: 700; } } .b { @include w { font-weight: 500; } }`, + ['font-weight: 700'], + ], + // Through nested mixins, in the order Sass writes them out. + [ + `@mixin inner { @content; } @mixin outer { font-weight: 500; @include inner { font-weight: 700; } } .x { font-family: ${mono}; @include outer; }`, + ['font-weight: 700'], + ], + [ + `@mixin inner { @content; } @mixin outer { font-weight: 700; @include inner { font-weight: 500; } } .x { font-family: ${mono}; @include outer; }`, + [], + ], + [ + `@mixin inner { @content; font-weight: 500; } @mixin outer { @include inner { font-weight: 700; } } .x { font-family: ${mono}; @include outer; }`, + [], + ], + [ + `@mixin inner { font-weight: 500; } @mixin outer { font-weight: 700; @include inner; } .x { font-family: ${mono}; @include outer; }`, + [], + ], + [ + `@mixin a { font-weight: 700; } @mixin b { @include a; } @mixin c { @include b; font-weight: 500; } .x { font-family: ${mono}; @include c; }`, + [], + ], + [ + `@mixin inner { @content; } @mixin outer { @include inner { font-family: Roboto; } font-family: ${mono}; } .x { font-weight: 700; @include outer; }`, + ['font-weight: 700'], + ], + [ + `@mixin inner { @content; } @mixin outer { font-family: ${mono}; @include inner { font-family: Roboto; } } .x { font-weight: 700; @include outer; }`, + [], + ], + // Placed in a nested rule, it styles another element. + [ + `@mixin w { font-weight: 700; .inner { @content; } } .x { @include w { font-family: ${mono}; } }`, + [], + ], + // A placeholder styles nothing where it is written; a class still does. + [ + `%h { font-family: ${mono}; font-weight: 700; } .x { @extend %h; font-family: Roboto; }`, + [], + ], + [ + `.m { font-family: ${mono}; font-weight: 700; } .x { @extend .m; font-family: Roboto; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test("inherits the document root's family", () => { + const mono = "'JetBrains Mono'"; + const media = '@media (min-width: 1px)'; + const report = (body) => + findOffScaleWeights('libs/s10/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [ + `html { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:root { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `body { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:host { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + // `*` sets each element itself, over what it inherits. + [ + `* { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `* { font-family: ${mono}; } .p { font-family: Roboto; .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `* { font-family: Roboto; } html { font-family: ${mono}; } .x { font-weight: 700; }`, + [], + ], + [ + `body { font-family: inherit; } html { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + // A nearer family wins, and `:host(.dark)` is no plain host. + [ + `html { font-family: ${mono}; } body { font-family: Roboto; } .x { font-weight: 700; }`, + [], + ], + [ + `html { font-family: ${mono}; } .x { font-family: Roboto; font-weight: 700; }`, + [], + ], + [ + `body { font-family: ${mono}; } .p { font-family: Roboto; .x { font-weight: 700; } }`, + [], + ], + [`:host(.dark) { font-family: ${mono}; } .x { font-weight: 700; }`, []], + // A root in the reader's `@media` applies with it; one in another + // context does not always. + [ + `${media} { html { font-family: ${mono}; } .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `html { font-family: Roboto; } ${media} { html { font-family: ${mono}; } .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { * { font-family: ${mono}; } .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { html { font-family: ${mono}; } } .x { font-weight: 700; }`, + [], + ], + // There, the cascade picks the root's family: a later rule, or a + // `:root` over `html` on the one root element, unless `!important`. + [ + `:root { font-family: ${mono}; } ${media} { :root { font-family: Roboto; } .x { font-weight: 700; } }`, + [], + ], + [ + `${media} { :root { font-family: Roboto; } } :root { font-family: ${mono}; } ${media} { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `html { font-family: ${mono}; } ${media} { :root { font-family: Roboto; } .x { font-weight: 700; } }`, + [], + ], + [ + `:root { font-family: Roboto; } html { font-family: ${mono}; } .x { font-weight: 700; }`, + [], + ], + [ + `:root { font-family: ${mono} !important; } ${media} { :root { font-family: Roboto; } .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `html { font-family: ${mono}; } ${media} { html { font-family: inherit; } .x { font-weight: 700; } }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads a static Sass interpolation in a family as Sass writes it', () => { + const report = (body) => + findOffScaleWeights('libs/s11/c.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [`.x { font-family: #{'JetBrains'} Mono; font-weight: 700; }`, true], + [`.x { font-family: #{"JetBrains Mono"}; font-weight: 700; }`, true], + [`.x { font-family: #{JetBrains} Mono; font-weight: 700; }`, true], + [`.x { font-family: '#{JetBrains} Mono'; font-weight: 700; }`, true], + [`.x { font-family: Jet#{'Brains'} Mono; font-weight: 700; }`, true], + [ + `.x { font-family: #{'Jet' + 'Brains'} Mono; font-weight: 700; }`, + true, + ], + [ + `.x { font-family: #{null}#{'JetBrains Mono'}; font-weight: 700; }`, + true, + ], + [ + `.x { font-family: #{'JetBrains'} #{'Mono'}, monospace; font-weight: 700; }`, + true, + ], + [`.x { font: 12px #{'JetBrains'} Mono; font-weight: 700; }`, true], + [ + `$f: #{'JetBrains'} Mono; .x { font-family: $f; font-weight: 700; }`, + true, + ], + // Its strings' escapes decoded as Sass unquotes them. + [ + `.x { font-family: #{'JetBrains\\20 Mono'}; font-weight: 700; }`, + true, + ], + [ + `.x { font-family: #{"JetBrains\\20 Mono"}; font-weight: 700; }`, + true, + ], + [ + `.x { font-family: #{'Jet' + 'Brains\\20 Mono'}; font-weight: 700; }`, + true, + ], + [ + `.x { font-family: #{"JetBrains\\" Mono"}; font-weight: 700; }`, + false, + ], + // A quote it writes out opens a string the browser never closes. + [ + `.x { font-family: #{'JetBrains Mono\\', x'}; font-weight: 700; }`, + false, + ], + // A string it writes out can hold the whole list. + [ + `.x { font-family: #{'JetBrains Mono, monospace'}; font-weight: 700; }`, + true, + ], + // Spaced operands are a list: `Jet Brains` is another face. + [`.x { font-family: #{'Jet' 'Brains'}; font-weight: 700; }`, false], + [`.x { font-family: #{'Roboto'}; font-weight: 700; }`, false], + // One that calls a function is left to its value. + [ + `.x { font-family: #{fn('JetBrains')} Mono; font-weight: 700; }`, + false, + ], + ]) { + assert.deepEqual( + report(source), + expected ? ['font-weight: 700'] : [], + source + ); + } +}); + +test('reads a complex rule as a base of the narrower ones it reaches', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s11/e.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // Compound by compound, last to last, each contained in the reader's. + [ + `.parent .x { font-family: ${mono}; } .parent .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent .x { font-family: ${mono}; } .w .parent .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent .x { font-family: ${mono}; } .parent.on .x.a { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.p .x { font-family: ${mono}; } .z .x:hover { font-weight: 700; }`, + [], + ], + // A descendant across `>` or ` ` chains, `~` across `+` or `~`. + [ + `.a .b { font-family: ${mono}; } .a > .b.c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.p .x { font-family: ${mono}; } .p .q .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a ~ .b { font-family: ${mono}; } .a + .b.c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [`.a > .b { font-family: ${mono}; } .a .b.c { font-weight: 700; }`, []], + [ + `.a + .b { font-family: ${mono}; } .a ~ .b.c { font-weight: 700; }`, + [], + ], + [ + `.a > .b { font-family: ${mono}; } .a > .q > .b { font-weight: 700; }`, + [], + ], + [ + `.a + .b { font-family: ${mono}; } .a + .q + .b { font-weight: 700; }`, + [], + ], + // Every place a span allows, not only the nearest match. + [ + `.a > .b .x { font-family: ${mono}; } .a > .b .b .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a + .b ~ .x { font-family: ${mono}; } .a + .b ~ .b ~ .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.a .b .c .x { font-family: ${mono}; } .a .c .b .x { font-weight: 700; }`, + [], + ], + // A simple selector however it is spelled. + [ + `.x { font-family: ${mono}; } [class~="x"] { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `#x { font-family: ${mono}; } [id="x"] { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `[ data-a = '1' ] { font-family: ${mono}; } [data-a="1"].on { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `div { font-family: ${mono}; } DIV.x { font-weight: 700; }`, + ['font-weight: 700'], + ], + // Chromium matches an SVG type in any case, too. + [ + `foreignobject { font-family: ${mono}; } foreignObject { font-weight: 700; }`, + ['font-weight: 700'], + ], + [`[class="x"] { font-family: ${mono}; } .x { font-weight: 700; }`, []], + // As an ancestor's rule, and ranked in the cascade. + [ + `.parent .x { font-family: ${mono}; } .parent .x:hover .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } .x.on .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent .x { font-family: ${mono}; } .parent .x:hover { font-family: Roboto; font-weight: 700; }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads a keyframe where a rule runs it, over its own', () => { + const mono = "'JetBrains Mono'"; + const run = 'animation: k 1ms steps(1) forwards;'; + const report = (body) => + findOffScaleWeights('libs/s11/f.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // A family or weight a keyframe sets, where a rule runs it. + [ + `.x { ${run} font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; ${run} } @keyframes k { to { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { animation-name: k; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `.p { ${run} } .p .x { font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + // Over the rule's own declarations, under `!important` ones. + [ + `.x { font-family: Roboto; ${run} font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: Roboto !important; ${run} font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + [], + ], + [ + `.x { font-family: ${mono}; font-weight: 500 !important; ${run} } @keyframes k { to { font-weight: 700; } }`, + [], + ], + // Whatever the order or specificity of the rule's own. + [ + `.x { ${run} font-family: Roboto; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; ${run} font-weight: 500; } @keyframes k { to { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { ${run} } .x.y { font-family: Roboto; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `.x { ${run} } .x.y { font-family: Roboto !important; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + [], + ], + // One that does not hold a frame is read both while it runs and after. + [ + `.x { font-family: ${mono}; font-weight: 700; animation: k 1ms; } @keyframes k { to { font-family: Roboto; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: Roboto; font-weight: 700; animation: k 1ms; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; font-weight: 500; animation: k 1ms; } @keyframes k { to { font-weight: 700; } }`, + ['font-weight: 700'], + ], + // The keyframes it runs: the last definition, the last declaration, + // a name rather than a keyword, and any for one read from a variable. + [ + `@keyframes k { to { font-family: ${mono}; } } @keyframes k { to { opacity: 0.5; } } .x { ${run} font-weight: 700; }`, + [], + ], + [ + `@keyframes k { to { opacity: 0.5; } } @keyframes k { to { font-family: ${mono}; } } .x { ${run} font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { ${run} animation: fade 1ms forwards; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } } @keyframes fade { to { opacity: 0.5; } }`, + [], + ], + [ + `@keyframes linear { to { font-family: ${mono}; } } .x { animation: fade 1ms linear forwards; font-weight: 700; } @keyframes fade { to { opacity: 0.5; } }`, + [], + ], + [ + `.x { --n: k; animation: var(--n) 1ms forwards; font-weight: 700; } @keyframes k { to { font-family: ${mono}; } }`, + ['font-weight: 700'], + ], + [ + `@keyframes linear { to { font-family: ${mono}; } } .x { animation: linear fade 1ms forwards; font-weight: 700; } @keyframes fade { to { opacity: 0.5; } }`, + [], + ], + [ + `@keyframes k { to { font-family: ${mono}; font-weight: 600; } } @keyframes k { to { opacity: 0.5; } }`, + [], + ], + // One that holds its frame replaces the rule's own after it, too. + [ + `.x { font-family: ${mono}; font-weight: 700; ${run} } @keyframes k { to { font-family: Roboto; } }`, + [], + ], + // Only the keyframes it names; a step's own family meets its weight, + // wherever it is run from. + [ + `.x { animation: fade 1ms; font-weight: 700; } @keyframes fade { to { opacity: 0.5; } } @keyframes k { to { font-family: ${mono}; } }`, + [], + ], + [ + `@keyframes k { to { font-family: ${mono}; font-weight: 600; } }`, + ['font-weight: 600'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads an `@at-root` rule where Sass writes it out', () => { + const mono = "'JetBrains Mono'"; + const media = '@media (min-width: 1px)'; + const report = (body) => + findOffScaleWeights('libs/s11/d.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // At the root, by its own selector: its source parent is no ancestor. + [ + `.x { font-family: ${mono}; @at-root .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [`.x { font-family: ${mono}; @at-root .y { font-weight: 700; } }`, []], + [ + `.x { font-family: ${mono}; @at-root { .y { font-weight: 700; } } }`, + [], + ], + [ + `.p { .x { font-family: ${mono}; } @at-root .x { font-weight: 700; } }`, + [], + ], + [ + `.p { font-family: ${mono}; .q { @at-root .x { font-weight: 700; } } }`, + [], + ], + [ + `.x { @at-root .x { font-family: ${mono}; } font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.w { @at-root .p { font-family: ${mono}; } } .p .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + // A query leaves out the blocks it names, and only those. + [ + `.x { font-family: ${mono}; @at-root (without: media) { .c { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; ${media} { @at-root (without: media) { .c { font-weight: 700; } } } }`, + ['font-weight: 700'], + ], + [ + `${media} { .x { font-family: ${mono}; @at-root (without: media) { .c { font-weight: 700; } } } }`, + [], + ], + [ + `${media} { .x { font-family: ${mono}; } } ${media} { .p { @at-root (without: media) { .x { font-weight: 700; } } } }`, + [], + ], + [ + `.x { font-family: ${mono}; @at-root (without: rule) { .c { font-weight: 700; } } }`, + [], + ], + [ + `.x { font-family: ${mono}; @at-root (with: rule) { .c { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; ${media} { @at-root (without: all) { .c { font-weight: 700; } } } }`, + [], + ], + [ + `.x { font-family: ${mono}; @at-root (without: media) { .y & { font-weight: 700; } } } .y { font-family: Roboto; }`, + ['font-weight: 700'], + ], + [ + `.p { font-family: ${mono}; .x { @at-root (without: media) { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `${media} { .x { @at-root (without: media) { .y & { font-weight: 700; } } } } .y { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + // Its `@media` stays, and a parent it names (`&`) is still one. + [ + `${media} { .x { font-family: ${mono}; @at-root .x { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; @at-root &.y { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; @at-root .y { .x & { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads `:is()` and `:where()` as the selectors they hold', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s11/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // The same elements written another way, either side. + [ + `:where(.parent .child) { font-family: ${mono}; } .parent .child { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent .child { font-family: ${mono}; } :where(.parent .child) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:is(.a .b, .c) { font-family: ${mono}; } .a .b { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:is(.a .b, .c) { font-family: ${mono}; } .c:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:where(.a .b) { font-family: ${mono}; } .z .b { font-weight: 700; }`, + [], + ], + // In any compound; whole selectors first or after a descendant + // combinator, where they read as elements it matches. + [ + `:where(.parent) .child { font-family: ${mono}; } .parent .child { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x :where(.p, .q) .c { font-family: ${mono}; } .x .q .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x :is(.a .b) { font-family: ${mono}; } .x .a .b { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x .a .b { font-family: ${mono}; } .x :is(.a .b) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x > :is(.a .b) { font-family: ${mono}; } .x > .a .b { font-weight: 700; }`, + [], + ], + // Beside other simples, which join its selectors' last compound. + [ + `:where(.parent .child).active { font-family: ${mono}; } .parent .child.active { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent .child.active { font-family: ${mono}; } :where(.parent .child).active { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x :is(.a .b).on { font-family: ${mono}; } .x .a .b.on { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `span:where(.p .c) { font-family: ${mono}; } .p span.c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `*:where(.p .c) { font-family: ${mono}; } .p .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:is(.p .c, .d).on { font-family: ${mono}; } .d.on { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x > :is(.a .b).on { font-family: ${mono}; } .x > .a .b.on { font-weight: 700; }`, + [], + ], + // Through wrappers nested in each other, however deep. + [ + `:where(:is(.parent .child)) { font-family: ${mono}; } .parent .child { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.parent .child { font-family: ${mono}; } :where(:is(.parent .child)) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:is(:where(.a .b), .c) .d { font-family: ${mono}; } .a .b .d { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:where(:is(:where(.p .c))).on { font-family: ${mono}; } .p .c.on { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x > :where(:is(.a .b)) { font-family: ${mono}; } .x > .a .b { font-weight: 700; }`, + [], + ], + // An unclosed one cannot be read, but its rule's weight still is. + [`.x:not(.a { font-weight: 650; }`, ['font-weight: 650']], + // Specificity counts through them too: `#a` outranks the later `.x`. + [ + `:is(:is(:is(#a))) { font-family: ${mono}; } .x { font-family: Roboto; } #a.x { font-weight: 700; }`, + ['font-weight: 700'], + ], + // Ranked with its own specificity: `:where()` counts nothing. + [ + `.parent .child { font-family: Roboto; } :where(.parent .child) { font-family: ${mono}; } .parent .child { font-weight: 700; }`, + [], + ], + [ + `.parent .child { font-family: ${mono}; } :is(.parent .child) { font-family: Roboto; } .parent .child { font-weight: 700; }`, + [], + ], + // As an ancestor or the document root. + [ + `:where(.a .p) { font-family: ${mono}; } .a .p .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.p { font-family: ${mono}; } :where(.p .c) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:where(html) { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads ancestors and bases as compiled, in their context', () => { + const mono = "'JetBrains Mono'"; + const media = '@media (min-width: 1px)'; + const report = (body) => + findOffScaleWeights('libs/s11/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // An ancestor in the same `@media`, or one that always applies. + [ + `${media} { .parent { font-family: ${mono}; } .parent .child { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.parent { font-family: ${mono}; } ${media} { .parent .child { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { .parent { font-family: ${mono}; } } .parent .child { font-weight: 700; }`, + [], + ], + // A family whose conditions are all the reader's applies there too, + // in any order; one under a further condition does not. + [ + `${media} { .x { font-family: ${mono}; } @supports (display: grid) { .x { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `@supports (display: grid) { .x { font-family: ${mono}; } } ${media} { @supports (display: grid) { .x { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `${media} { html { font-family: ${mono}; } @supports (display: grid) { .x { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `@if $c { .x { font-family: ${mono}; } @if $d { .x { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `${media} { @supports (display: grid) { .x { font-family: ${mono}; } } .x { font-weight: 700; } }`, + [], + ], + // Conditions compare however they are spaced or cased. + [ + `${media} { .x { font-family: ${mono}; } } @media (min-width:1px) { @supports (display: grid) { .x { font-weight: 700; } } }`, + ['font-weight: 700'], + ], + [ + `@MEDIA ( min-width : 1px ) { .x { font-family: ${mono}; } } ${media} { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { .x { font-family: ${mono}; } } @media (min-width: 2px) { .x { font-weight: 700; } }`, + [], + ], + // Quoted text in a condition is kept as written. + [ + `@container style(--t: "a, b") { .x { font-family: ${mono}; } } @container style(--t: "a,b") { .x { font-weight: 700; } }`, + [], + ], + [ + `@container style(--t: "a, b") { .x { font-family: ${mono}; } } @container style( --t : "a, b" ) { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + // A parent after a prefix (`.x &`) names more ancestors, read once + // the parent itself (the same element) sets no family. + [ + `.y { .x & { font-weight: 700; } } .x { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `.y { font-family: Roboto; .x & { font-weight: 700; } } .x { font-family: ${mono}; }`, + [], + ], + [ + `.x { font-family: ${mono}; } .w .y { .x & { font-weight: 700; } }`, + ['font-weight: 700'], + ], + // The ancestor's family is the cascade's winner there. + [ + `.parent { font-family: ${mono}; } ${media} { .parent { font-family: Roboto; } .parent .child { font-weight: 700; } }`, + [], + ], + [ + `${media} { .parent { font-family: Roboto; } } .parent { font-family: ${mono}; } ${media} { .parent .child { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.w .parent { font-family: Roboto; } .parent { font-family: ${mono}; } .w .parent .child { font-weight: 700; }`, + [], + ], + // A nested selector's ancestors, as compiled, by whole selector or by + // their own compound, nearest first. + [ + `.wrapper { .parent .child { font-weight: 700; } } .parent { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + [ + `.wrapper { .parent { font-family: ${mono}; } .parent .child { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.wrapper .parent { font-family: ${mono}; } .wrapper { .parent .child { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.w { font-family: Roboto; .p .c { font-weight: 700; } } .w .p { font-family: ${mono}; }`, + ['font-weight: 700'], + ], + // A base outside a `@media` reaches into it, not the other way. + [ + `.x { font-family: ${mono}; } ${media} { .x { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; } ${media} { .x:hover { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { .x { font-family: ${mono}; } .x:hover { font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { .x { font-family: ${mono}; } } .x:hover { font-weight: 700; }`, + [], + ], + // The parent of `&:hover` is the same element: its family beats an + // ancestor's, and `*`. + [ + `.p { font-family: Roboto; } .p .x { font-family: ${mono}; &:hover { font-weight: 700; } }`, + ['font-weight: 700'], + ], + + [ + `.x { font-family: ${mono}; &:hover { font-weight: 700; } } * { font-family: Roboto; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads an explicit `inherit` of a non-inheriting registered property', () => { + const mono = "'JetBrains Mono'"; + const read = 'font-family: var(--face); font-weight: 700;'; + const registered = + "@property --face { syntax: '*'; inherits: false; initial-value: Roboto; }"; + const report = (body) => + findOffScaleWeights('libs/s11/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // `inherit` takes the parent's value; `unset` gives the initial one. + for (const [own, expected] of [ + ['--face: inherit;', ['font-weight: 700']], + ['--face: unset;', []], + ['', []], + ]) { + assert.deepEqual( + report( + `${registered} body { --face: ${mono}; } .x { ${own} ${read} }` + ), + expected, + own + ); + } + // Another rule's `inherit` is not the reader's. + assert.deepEqual( + report( + `${registered} body { --face: ${mono}; } .y { --face: inherit; } .x { ${read} }` + ), + [] + ); +}); + +test("picks the cascade's winner among the rules on an element", () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s12/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // `!important`, then specificity, then source order, over the element's + // own rule, the bases it contains and `*`. + for (const [source, expected] of [ + [ + `.x { font-family: ${mono}; } .y { font-family: Roboto; } .x.y { font-weight: 700; }`, + [], + ], + [ + `.y { font-family: Roboto; } .x { font-family: ${mono}; } .x.y { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `div { font-family: ${mono}; } .x { font-family: Roboto; } div.x:hover { font-weight: 700; }`, + [], + ], + [ + `.x { font-family: Roboto; } div { font-family: ${mono}; } div.x:hover { font-weight: 700; }`, + [], + ], + [ + `.x { font-family: ${mono}; } .x.active { font-family: Roboto; } .x.active:hover { font-weight: 700; }`, + [], + ], + [ + `.x { font-family: ${mono} !important; } .x.active { font-family: Roboto; } .x.active:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x:hover { font-family: Roboto; font-weight: 700; } .x { font-family: ${mono}; }`, + [], + ], + [ + `.x:hover { font-weight: 700; } .x { font-family: ${mono}; } * { font-family: Roboto; }`, + ['font-weight: 700'], + ], + [ + `* { font-family: ${mono}; } .x { font-family: Roboto; } .x:hover { font-weight: 700; }`, + [], + ], + // A rule's place is where its winning declaration is. + [ + `.x { font-family: ${mono}; } .y { font-family: Roboto; } .x { font-family: ${mono}; } .x.y { font-weight: 700; }`, + ['font-weight: 700'], + ], + // An id outranks any number of classes; `:is()` takes its most + // specific argument. + [ + `#a { font-family: Roboto; } .x { font-family: ${mono}; } #a.x { font-weight: 700; }`, + [], + ], + [ + `:is(.x, #y) { font-family: Roboto; } .x { font-family: ${mono}; } .x:hover { font-weight: 700; }`, + [], + ], + // `:nth-child(… of S)` counts `S` too, besides itself; nested + // `:is()`/`:where()` open. + [ + `:nth-child(1 of #i) { font-family: ${mono}; } #j { font-family: Roboto; } #j:nth-child(1 of #i) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x:nth-child(1 of #item) { font-family: ${mono}; } .x.y { font-family: Roboto; } .x.y:nth-child(1 of #item):hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:is(:where(.x)) { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:is(.a, :where(.x, .y)) { font-family: ${mono}; } .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + // A reader's own `:where()`/`:is()` opens too. + [ + `.x { font-family: ${mono}; } :where(.x) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.y { font-family: ${mono}; } :is(.x, .y) { font-weight: 700; }`, + ['font-weight: 700'], + ], + [`.z { font-family: ${mono}; } :is(.x, .y) { font-weight: 700; }`, []], + // More than 16 ways to read one is left unopened. + [ + `:is(.a, .b, .c, .d, .e):is(.f, .g, .h, .i) { font-family: ${mono}; } .a.f { font-weight: 700; }`, + [], + ], + // `:where()` and `:is()` open into their selectors; `:where()` + // counts nothing toward specificity. + [ + `:where(.x) { font-family: ${mono}; } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `:where(.x) { font-family: ${mono}; } .x { font-family: Roboto; font-weight: 700; }`, + [], + ], + [ + `.x { font-family: Roboto; } :where(.x) { font-family: ${mono}; } .x:hover { font-weight: 700; }`, + [], + ], + [ + `:is(.x, .y) { font-family: ${mono}; } .y { font-weight: 700; }`, + ['font-weight: 700'], + ], + [`:is(.x, .y) { font-family: ${mono}; } .z { font-weight: 700; }`, []], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('follows `@extend` through extenders and inside `@media`', () => { + const mono = "'JetBrains Mono'"; + const media = '@media (min-width: 1px)'; + const report = (body) => + findOffScaleWeights('libs/s12/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + [ + `%mono { font-family: ${mono}; } %base { @extend %mono; } .x { @extend %base; font-weight: 700; }`, + ['font-weight: 700'], + ], + // Inside a `@media`, an `@extend` reaches only the same `@media`; from + // outside, it reaches into one. + [ + `${media} { %mono { font-family: ${mono}; } .x { @extend %mono; font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `${media} { %mono { font-family: ${mono}; } } .x { @extend %mono; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `%mono { font-family: ${mono}; } ${media} { .x { @extend %mono; font-weight: 700; } }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads cascade layers as always applying, ranked by layer', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s13/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // A layer is no condition. An unlayered declaration beats a layered one + // whatever its specificity or place; `!important` turns that round. + for (const [source, expected] of [ + // `revert-layer` rolls the cascade back past its own layer. + [ + `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: revert-layer; font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { all: revert-layer; font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@layer base { .x { font-family: ${mono}; } } .x { font-family: revert-layer; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } @layer top { .x { font-family: revert-layer; font-weight: 700; } }`, + [], + ], + [ + `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: revert-layer; } } @layer top { .x { font-family: revert-layer; font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; font-family: revert-layer; font-weight: 700; } }`, + ['font-weight: 700'], + ], + [ + `@layer base { .x { font-family: ${mono}; } } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@layer base { .x { font-family: ${mono}; } } .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@layer base { .p { font-family: ${mono}; } } .p .c { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { font-family: Roboto; } @layer base { .x { font-family: ${mono}; } } .x:hover { font-weight: 700; }`, + [], + ], + [ + `@layer base { .d.e { font-family: ${mono}; } } .d { font-family: Roboto; } .d.e:hover { font-weight: 700; }`, + [], + ], + [ + `@layer base { .x { font-family: ${mono} !important; } } .x { font-family: Roboto !important; } .x:hover { font-weight: 700; }`, + ['font-weight: 700'], + ], + // Named layers rank in declared order (`@layer a, b;`, else as they + // first appear), later winning; `!important` reverses it. + [ + `@layer theme, base; @layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@layer base, theme; @layer base { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } .x { font-weight: 700; }`, + [], + ], + [ + `@layer theme, base; @layer base { .x { font-family: ${mono} !important; } } @layer theme { .x { font-family: Roboto !important; } } .x { font-weight: 700; }`, + [], + ], + [ + `@layer one { .x { font-family: Roboto; } } @layer two { .x { font-family: ${mono}; } } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `@layer { .x { font-family: Roboto; } } @layer two { .x { font-family: ${mono}; } } .x { font-weight: 700; }`, + ['font-weight: 700'], + ], + // A nested layer ranks with its top-level layer. + [ + `@layer a, c; @layer a { @layer b { .x { font-family: ${mono}; } } } @layer c { .x { font-family: Roboto; } } .x { font-weight: 700; }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('ranks nested and unnamed cascade layers by their full place', () => { + const mono = "'JetBrains Mono'"; + const weight = '.x { font-weight: 700; }'; + const report = (body) => + findOffScaleWeights('libs/s14/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // Sublayers rank in their declared order within their layer. + [ + `@layer base { @layer theme, fonts; @layer fonts { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } } ${weight}`, + ['font-weight: 700'], + ], + [ + `@layer base { @layer fonts, theme; @layer fonts { .x { font-family: ${mono}; } } @layer theme { .x { font-family: Roboto; } } } ${weight}`, + [], + ], + // Each unnamed layer is its own, the later winning. + [ + `@layer named { .x { font-family: ${mono}; } } @layer { .x { font-family: Roboto; } } ${weight}`, + [], + ], + [ + `@layer { .x { font-family: Roboto; } } @layer { .x { font-family: ${mono}; } } ${weight}`, + ['font-weight: 700'], + ], + [ + `@layer { .x { font-family: ${mono}; } } @layer { .x { font-family: Roboto; } } ${weight}`, + [], + ], + // A layer's own declarations beat its sublayers', `!important` the + // other way; `x.y` is a sublayer of `x`. + [ + `@layer p { .x { font-family: ${mono}; } @layer q { .x { font-family: Roboto; } } } ${weight}`, + ['font-weight: 700'], + ], + [ + `@layer p { .x { font-family: ${mono} !important; } @layer q { .x { font-family: Roboto !important; } } } ${weight}`, + [], + ], + [ + `@layer x.y { .x { font-family: Roboto; } } @layer x { .x { font-family: ${mono}; } } ${weight}`, + ['font-weight: 700'], + ], + // A layer is placed where it first appears, a block with no family + // or a dotted name (`x.y` places `x`) included. + [ + `@layer a { .y { color: red; } } @layer b { .x { font-family: Roboto; } } @layer a { .x { font-family: ${mono}; } } ${weight}`, + [], + ], + [ + `@layer x.y, z; @layer z { .x { font-family: Roboto; } } @layer x { .x { font-family: ${mono}; } } ${weight}`, + [], + ], + // However deep: five levels compare as fully as one. + [ + `@layer a.b.c.d.theme, a.b.c.d.fonts; @layer a.b.c.d.fonts { .x { font-family: ${mono}; } } @layer a.b.c.d.theme { .x { font-family: Roboto; } } ${weight}`, + ['font-weight: 700'], + ], + [ + `@layer a.b.c.d.fonts, a.b.c.d.theme; @layer a.b.c.d.fonts { .x { font-family: ${mono}; } } @layer a.b.c.d.theme { .x { font-family: Roboto; } } ${weight}`, + [], + ], + [ + `@layer a.b.c.d { .x { font-family: ${mono}; } } @layer a.b.c.d.e { .x { font-family: Roboto; } } ${weight}`, + ['font-weight: 700'], + ], + [ + `@layer a.b.c.d { .x { font-family: ${mono} !important; } } @layer a.b.c.d.e { .x { font-family: Roboto !important; } } ${weight}`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads an `all` reset as resetting the family and weight', () => { + const mono = "'JetBrains Mono'"; + const parent = `.parent { font-family: ${mono}; }`; + const report = (body) => + findOffScaleWeights('libs/s15/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + for (const [source, expected] of [ + // `unset` and `revert` inherit the family, `initial` takes the + // browser's; a later or `!important` family still wins. + [ + `${parent} .parent .a { font-family: Roboto; all: unset; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `${parent} .parent .a { font-family: Roboto; all: revert; font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `${parent} .parent .a { font-family: Roboto; all: initial; font-weight: 700; }`, + [], + ], + [ + `${parent} .parent .a { all: unset; font-family: Roboto; font-weight: 700; }`, + [], + ], + [ + `${parent} .parent .a { font-family: Roboto !important; all: unset; font-weight: 700; }`, + [], + ], + // A weight before it is reset too, unless `!important`; and it resets + // the family either way. + [`.a { font-family: ${mono}; font-weight: 700; all: unset; }`, []], + [ + `.a { font-family: ${mono}; font-weight: 700 !important; all: unset; }`, + [], + ], + [ + `.a { all: unset; font-family: ${mono}; font-weight: 700; }`, + ['font-weight: 700'], + ], + // An `!important` reset beats a later family, and a later weight + // unless that is `!important` too. + [ + `${parent} .parent .a { all: unset !important; font-family: Roboto; font-weight: 700 !important; }`, + ['font-weight: 700'], + ], + [ + `${parent} .parent .a { all: unset !important; font-family: Roboto; font-weight: 700; }`, + [], + ], + // A weight before it is gone even where the family is set again + // after it, and an earlier `!important` weight outranks a later one. + [`.a { font-weight: 700; all: unset; font-family: ${mono}; }`, []], + [ + `.a { font-family: ${mono}; font-weight: 500 !important; font-weight: 700; }`, + [], + ], + // Through a mixin, at its `@include`. + [ + `@mixin reset { all: unset; } ${parent} .parent .a { font-family: Roboto; font-weight: 700; @include reset; }`, + [], + ], + [ + `@mixin reset { all: unset; } ${parent} .parent .a { font-family: Roboto; @include reset; font-weight: 700; }`, + ['font-weight: 700'], + ], + // Only as a declaration: not in another value or a Sass map. + [ + `${parent} .parent .a { font-family: Roboto; --token: all: unset; font-weight: 700; }`, + [], + ], + [ + `${parent} .parent .a { font-family: Roboto; $m: (all: unset); font-weight: 700; }`, + [], + ], + [`.a { --face: font-family: ${mono}; font-weight: 700; }`, []], + [ + `.a { font-family: ${mono}; font-weight: 700; --x: font-weight: 400; }`, + ['font-weight: 700'], + ], + [ + `.a { font-family: ${mono}; font-weight: 700; --t: all: unset; }`, + ['font-weight: 700'], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('compiles a nested `&` after its parent', () => { + const mono = "'JetBrains Mono'"; + const report = (body) => + findOffScaleWeights('libs/s13/b.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + // `.x { & .child {} }` is `.x .child`, not `.child .x`. + for (const [source, expected] of [ + [ + `.x { & .child { font-family: ${mono}; } } .x .child .item { font-weight: 700; }`, + ['font-weight: 700'], + ], + [ + `.x { & .child { font-family: ${mono}; } } .child .x .item { font-weight: 700; }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } +}); + +test('reads template literals set from code', () => { + const component = [ + "renderer.setStyle(el, 'font-weight', `65${0}`);", + 'el.style.fontWeight = `${wide ? 750 : 600}`;', + "el.style.fontWeight = `${'6'}${'50'}`;", + // An unknown value alone is that value; text around it computes one. + 'el.style.fontWeight = `${weight}`;', + 'el.style.fontWeight = `6${w}`;', + // A shorthand's other tokens still read as CSS. + 'el.style.font = `${650} 12px Roboto`;', + 'el.style.font = `${w} 12px Roboto`;', + // So does CSS text in a template. + 'el.style.cssText = `font-weight: 65${0}`;', + 'el.style.fontWeight = `600`;', + // A number as JavaScript writes it into a string. + 'el.style.fontWeight = `${0x28a}`;', + // A nested template, or more than 16 texts, is not expanded. + 'el.style.fontWeight = `${`6${5}`}0`;', + 'el.style.fontWeight = `${a ? 6 : 6}${b ? 5 : 5}${c ? 0 : 0}${d ? 0 : 0}${e ? 0 : 0}`;', + ].join('\n'); + + assert.deepEqual( + findOffScaleWeights('apps/web/src/a.component.ts', component) + .findings.map(({ line, value, computed }) => + computed ? `${line} ${value} (computed)` : `${line} ${value}` + ) + .sort(), + [ + '1 650', + '10 650', + '11 `${`6${5}`}0` (computed)', + '12 `${a ? 6 : 6}${b ? 5 : 5}${c ? 0 : 0}${d ? 0 : 0}${e ? 0 : 0}` (computed)', + '2 750', + '3 650', + '5 `6${w}` (computed)', + '6 650', + '8 650', + ] + ); +}); + +test('reads custom properties as the cascade applies them', () => { + const read = '.x { font-weight: var(--w); }'; + // A later declaration in the same rule replaces an earlier one. + assert.deepEqual( + offScale('libs/m5/a.scss', `:root { --w: 650; --w: 600; } ${read}`), + [] + ); + for (const rule of [ + ':root { --w: 650 !important; --w: 600; }', + ':root { --w: 650; @if $a { --w: 600; } }', + ]) { + assert.deepEqual(offScale('libs/m5/a.scss', `${rule} ${read}`), [ + '1 --w: 650', + ]); + } + + // A fallback stays live unless a rule that reaches the reading rule + // sets the property: `:root`, the rule itself, an enclosing one, or the + // component's `:host`. + const mono = + "font-family: var(--face, 'JetBrains Mono'); font-weight: 700;"; + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const rule = (body) => scanWeights('libs/m5/rule.scss', body); + assert.deepEqual(report([rule(`.a { --face: Roboto; } .b { ${mono} }`)]), [ + 'libs/m5/rule.scss:1 700', + ]); + for (const body of [ + `.b { --face: Roboto; ${mono} }`, + `.a { --face: Roboto; .b { ${mono} } }`, + `:host { --face: Roboto; } .b { ${mono} }`, + `html, .theme { --face: Roboto; } .b { ${mono} }`, + `:where(:root) { --face: Roboto; } .b { ${mono} }`, + `:is(html, .theme) { --face: Roboto; } .b { ${mono} }`, + `:host, :host(.x) { --face: Roboto; } .b { ${mono} }`, + `@media (min-width: 1px) { .b { --face: Roboto; ${mono} } }`, + ]) { + assert.deepEqual(report([rule(body)]), []); + } + // `body` sits below `html`, so it cannot set a property `html` reads. + assert.deepEqual( + report([rule(`body { --face: Roboto; } html { ${mono} }`)]), + ['libs/m5/rule.scss:1 700'] + ); + assert.deepEqual( + report([rule(`body { --face: Roboto; } .b { ${mono} }`)]), + [] + ); + // A definition under a condition, or a host state, may not apply; one + // the reading declaration shares does. + assert.deepEqual( + report([rule(`@if $on { .b { --face: Roboto; ${mono} } }`)]), + [] + ); + for (const body of [ + `.b { @if $on { --face: Roboto; } ${mono} }`, + `@media (min-width: 600px) { :root { --face: Roboto; } } .b { ${mono} }`, + `@if $dark { :root { --face: Roboto; } } .b { ${mono} }`, + `:host(.light) { --face: Roboto; } .b { ${mono} }`, + `@media (min-width: #{$bp}) { :root { --face: Roboto; } } .b { ${mono} }`, + ]) { + assert.deepEqual(report([rule(body)]), ['libs/m5/rule.scss:1 700']); + } + assert.deepEqual( + report([ + scanWeights('libs/m5/other.scss', ':host { --face: Roboto; }'), + rule(`.b { ${mono} }`), + ]), + ['libs/m5/rule.scss:1 700'] + ); + // The same condition in another file is another rule's condition. + const media = '@media (min-width: 1px) {'; + assert.deepEqual( + report([ + scanWeights( + 'libs/m5/other.scss', + `${media} :root { --face: Roboto; } }` + ), + rule(`${media} .b { ${mono} } }`), + ]), + ['libs/m5/rule.scss:1 700'] + ); +}); + +test('reads a family Sass assembles from variables', () => { + const report = (body) => + findIndirectWeights([scanWeights('libs/m5/s.scss', body)]).map( + ({ line, value }) => `${line} ${value}` + ); + const weight = 'font-weight: 700;'; + + assert.deepEqual( + report( + `$prefix: JetBrains; .x { font-family: '#{$prefix} Mono'; ${weight} }` + ), + ['1 700'] + ); + assert.deepEqual( + report( + `$a: 'JetBrains'; $b: Mono; .x { font-family: $a $b, monospace; ${weight} }` + ), + ['1 700'] + ); + assert.deepEqual( + report( + `$base: JetBrains; $prefix: $base; .x { font-family: '#{$prefix} Mono'; ${weight} }` + ), + ['1 700'] + ); + assert.deepEqual( + report( + `$prefix: Roboto; .x { font-family: '#{$prefix} Mono'; ${weight} }` + ), + [] + ); + // Chains of any length resolve, and a cycle ends. + assert.deepEqual( + report( + `$a: JetBrains; $b: $a; $c: $b; $d: $c; $prefix: $d; .x { font-family: '#{$prefix} Mono'; ${weight} }` + ), + ['1 700'] + ); + assert.deepEqual( + report(`$a: $b; $b: $a; .x { font-family: '#{$a} Mono'; ${weight} }`), + [] + ); + // A package module's variable cannot be resolved; the name written + // beside it still counts. + assert.deepEqual( + report( + `@use 'pkg:lib' as lib; $mono: 'JetBrains Mono', lib.$rest; .x { font-family: $mono; ${weight} }` + ), + ['1 700'] + ); +}); + +test('resolves family variables in their place in the list', () => { + const report = (body) => + findOffScaleWeights('libs/m8/a.scss', body).findings.map( + ({ name, value }) => `${name}: ${value}` + ); + const shorthand = "font: 700 var(--size) var(--face), 'JetBrains Mono';"; + + // `var(--size)` is the size; `var(--face)` comes first in the list. + assert.deepEqual( + report(`:root { --face: Roboto; --size: 12px; } .x { ${shorthand} }`), + [] + ); + assert.deepEqual( + report( + `:root { --face: 'SF Mono'; --size: 12px; } .x { ${shorthand} }` + ), + ['font: 700'] + ); + assert.deepEqual( + report( + ":root { --face: Roboto; } .y { font-family: var(--face), 'JetBrains Mono'; font-weight: 700; }" + ), + [] + ); + // A comma set apart still ends the entry before it. + assert.deepEqual( + report( + ":root { --face: Roboto; --size: 12px; } .x { font: 700 var(--size) var(--face) , 'JetBrains Mono'; }" + ), + [] + ); + // Weight and size from variables: the family follows the size. + assert.deepEqual( + report( + ":root { --weight: 700; --size: 12px; } .x { font: var(--weight) var(--size) 'JetBrains Mono'; }" + ), + ['--weight: 700'] + ); + // A whole shorthand from variables, its family a variable too. + assert.deepEqual( + report( + ":root { --size: 16px; --face: 'JetBrains Mono'; --body: 700 var(--size) var(--face); } .x { font: var(--body); }" + ), + ['--body: 700'] + ); + // Any branch that can render Mono counts. + assert.deepEqual( + report( + ":root { --face: Roboto; } .z { --face: 'SF Mono'; } .y { font-family: var(--face), 'JetBrains Mono'; font-weight: 700; }" + ), + ['font-weight: 700'] + ); +}); + +test('caps a var() branch only where its own family can render Mono', () => { + const report = (body) => + findIndirectWeights([scanWeights('libs/m7/a.scss', body)]).map( + ({ line, value }) => `${line} ${value}` + ); + const fallback = "font: var(--f, 500 16px 'JetBrains Mono');"; + + assert.deepEqual( + report(`.a { --f: 700 16px Roboto; } .x { ${fallback} }`), + [] + ); + assert.deepEqual( + report(`.a { --f: 700 16px 'JetBrains Mono'; } .x { ${fallback} }`), + ['1 700'] + ); + assert.deepEqual( + report(".a { --w: 700; } .x { font: var(--w) 16px 'JetBrains Mono'; }"), + ['1 700'] + ); + // A whole shorthand from a variable is read as a shorthand. + assert.deepEqual( + report(".a { --f: 700 16px 'JetBrains Mono'; } .x { font: var(--f); }"), + ['1 700'] + ); + // A family the shorthand reads through a variable may be Mono. + assert.deepEqual( + report( + `.a { --face: 'JetBrains Mono'; --f: 700 16px var(--face); } .x { ${fallback} }` + ), + ['1 700'] + ); +}); + +test('caps the weights a JetBrains Mono rule reads through variables', () => { + const mono = "font-family: 'JetBrains Mono'"; + assert.deepEqual( + offScale( + 'libs/m3/b.scss', + `$mono-w: 600; .a { ${mono}; font-weight: $mono-w; } .b { font-weight: $mono-w; }` + ), + ['1 $mono-w: 600'] + ); + // A `…weight` name is checked where it is declared; the cap adds one + // finding, and an off-scale value is not reported twice. + assert.deepEqual( + offScale( + 'libs/m3/c.scss', + `$title-weight: 600; .a { ${mono}; font-weight: $title-weight; }` + ), + ['1 $title-weight: 600'] + ); + assert.deepEqual( + offScale( + 'libs/m3/d.scss', + `$title-weight: 650; .a { ${mono}; font-weight: $title-weight; }` + ), + ['1 $title-weight: 650'] + ); + assert.deepEqual( + offScale( + 'libs/m3/e.scss', + ".x { --w: 700; } .a { font: var(--w) 12px 'JetBrains Mono'; }" + ), + ['1 --w: 700'] + ); + // A `…weight` keyword is not reported again. + assert.deepEqual( + offScale( + 'libs/m3/f.scss', + `$title-weight: bolder; .a { ${mono}; font-weight: $title-weight; }` + ), + ['1 $title-weight: bolder'] + ); + // A chain read from both kinds of rule keeps the cap, in either order. + for (const rules of [ + `.n { font-weight: $a; } .m { ${mono}; font-weight: $a; }`, + `.m { ${mono}; font-weight: $a; } .n { font-weight: $a; }`, + ]) { + assert.deepEqual( + offScale('libs/m3/g.scss', `$b: 600; $a: $b; ${rules}`), + ['1 $b: 600'] + ); + } + // Custom properties set from code, as CSS text or as an expression. + const host = scanWeights( + 'libs/m3/x.component.ts', + [ + "host: { '[style.--mono-w]': \"'700'\",", + " '[style.--mono-v]': 'wide ? 700 : 500' }", + ].join('\n') + ); + const rule = scanWeights( + 'libs/m3/x.component.scss', + `.a { ${mono}; font-weight: var(--mono-w); } .b { ${mono}; font-weight: var(--mono-v); }` + ); + assert.deepEqual( + findIndirectWeights([host, rule]) + .map(({ line, value }) => `${line} ${value}`) + .sort(), + ['1 700', '2 700'] + ); +}); + +test('lets every configured load replace a partial default', () => { + const report = (scans) => + findIndirectWeights(scans).map( + ({ file, line, value }) => `${file}:${line} ${value}` + ); + const rule = '$w: 650 !default; .t { font-weight: $w; }'; + const tokens = scanWeights('libs/c3/_tokens.scss', rule); + const load = (file, body) => scanWeights(`libs/c3/${file}`, body); + const configured = load('a.scss', "@use 'tokens' with ($w: 600);"); + const flagged = ['libs/c3/_tokens.scss:1 650']; + + assert.deepEqual(report([tokens, configured]), []); + // A quoted `;` or `//` in the configuration is a value. + assert.deepEqual( + report([ + tokens, + load( + 'a.scss', + "@use 'tokens' with ($asset: 'data:image/svg+xml;utf8,x', $w: 600);" + ), + ]), + [] + ); + assert.deepEqual( + report([ + tokens, + load('a.scss', "@use 'tokens' with ($asset: '//cdn/x', $w: 600);"), + ]), + [] + ); + assert.deepEqual( + report([ + tokens, + load('a.scss', "@use 'tokens' with ($asset: '//cdn/x', $w: 750);"), + ]), + ['libs/c3/a.scss:1 750'] + ); + assert.deepEqual( + report([tokens, configured, load('b.scss', "@use 'tokens';")]), + flagged + ); + assert.deepEqual(report([tokens]), flagged); + // Only a `with (…)` setting that name counts. + assert.deepEqual( + report([tokens, load('a.scss', "@use 'tokens' with ($other: 600);")]), + flagged + ); + assert.deepEqual( + report([ + tokens, + configured, + load('b.scss', "@use 'tokens'; .x { @include m($w: 600); }"), + ]), + flagged + ); + // A block's own `!default` is not a module setting. + assert.deepEqual( + report([ + scanWeights( + 'libs/c3/_local.scss', + '.t { $w: 650 !default; font-weight: $w; }' + ), + load('a.scss', "@use 'local' with ($w: 600);"), + ]), + ['libs/c3/_local.scss:1 650'] + ); + // Forwarding cycles end. + assert.deepEqual( + report([ + scanWeights('libs/c3/_x.scss', `@forward 'y'; ${rule}`), + load('_y.scss', "@forward 'x';"), + ]), + ['libs/c3/_x.scss:1 650'] + ); + assert.deepEqual( + report([tokens, load('a.scss', "@use 'tokens' with ($w: null);")]), + flagged + ); + // A file that is not a partial may also be compiled on its own. + assert.deepEqual( + report([ + scanWeights('libs/c3/entry.scss', rule), + load('a.scss', "@use 'entry' with ($w: 600);"), + ]), + ['libs/c3/entry.scss:1 650'] + ); + // Through a `@forward`, its own `with (…)` or its loads, under the prefix. + assert.deepEqual( + report([ + tokens, + load('_mod.scss', "@forward 'tokens' with ($w: 600);"), + load('u.scss', "@use 'mod';"), + ]), + [] + ); + const mod = load('_mod.scss', "@forward 'tokens' as p-*;"); + assert.deepEqual( + report([tokens, mod, load('u.scss', "@use 'mod' with ($p-w: 600);")]), + [] + ); + assert.deepEqual( + report([ + tokens, + mod, + load('u.scss', "@use 'mod' with ($p-w: 600);"), + load('v.scss', "@use 'mod';"), + ]), + flagged + ); +}); + +test('blanks every `//` comment in TypeScript', () => { + const component = [ + 'call(// font-weight: 650 was removed', + ' value);', + 'const a = { b: // font-weight: 750', + ' 1 };', + ].join('\n'); + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), []); +}); + +test('flags relative keywords, which can land off the scale', () => { + const findings = findOffScaleWeights( + 'libs/a.scss', + 'strong { font-weight: bolder; }' + ).findings; + + assert.deepEqual( + findings.map(({ value }) => value), + ['bolder'] + ); + assert.match(describeFinding(findings[0]), /relative to the parent/); +}); + +test('ignores weights in comments and keeps later line numbers exact', () => { + const source = [ + '/* The heading used to be', + ' font-weight: 650; */', + '// font-weight: 750 looked too heavy', + ".a { src: url('https://example.test/f.woff2'); }", + '.b { font-weight: 450; }', + ].join('\n'); + + assert.deepEqual(offScale('libs/a.scss', source), ['5 font-weight: 450']); + assert.equal(blankComments(source).split('\n').length, 5); + assert.match(blankComments(source), /https:\/\/example\.test/); +}); + +test('treats a selector that merely contains "weight" as a selector', () => { + const source = '.title-weight:nth-child(2) { font-weight: 600; }'; + + assert.deepEqual(offScale('libs/a.scss', source), []); +}); + +test('checks CSS text in TypeScript and HTML, not variables named …Weight', () => { + const component = [ + 'const yearWeight = 100;', + 'const ranking = { popularityWeight: 0.3, titleWeight: 250 };', + 'styles: [', + " '.chip { font-size: 0.72rem; font-weight: 650; }',", + '],', + 'const style = { fontWeight: 750 };', + ].join('\n'); + const template = '

Hi

'; + + assert.deepEqual(offScale('apps/web/src/a.component.ts', component), [ + '4 font-weight: 650', + '6 fontWeight: 750', + ]); + assert.deepEqual(offScale('apps/web/src/index.html', template), [ + '1 font-weight: 300', + ]); +}); + +test('suggests the scale step the audit mapping uses', () => { + const mapping = Object.fromEntries( + [300, 450, 520, 530, 610, 650, 680, 750, 760, 800].map((weight) => [ + weight, + nearestScaleWeight(weight), + ]) + ); + + assert.deepEqual(mapping, { + 300: 400, + 450: 500, + 520: 500, + 530: 500, + 610: 600, + 650: 600, + 680: 600, + 750: 700, + 760: 700, + 800: 700, + }); + assert.match( + describeFinding({ + file: 'libs/a.scss', + line: 3, + name: 'font-weight', + value: 650, + }), + /^libs\/a\.scss:3 font-weight: 650 is off the 400\/500\/600\/700 scale.*Use 600\.$/ + ); +}); + +test('scans app and library styles and sources, not the landing site', () => { + assert.equal(isScannedFile('libs/ui/styles/_detail-view.scss'), true); + assert.equal(isScannedFile('apps/web/src/styles.scss'), true); + assert.equal(isScannedFile('apps/web/src/app/app.component.ts'), true); + assert.equal(isScannedFile('apps/web/src/index.html'), true); + assert.equal(isScannedFile('apps/web/src/assets/images/logo.svg'), true); + assert.equal(isScannedFile('apps/website/src/styles/global.css'), false); + assert.equal(isScannedFile('tools/nx/check-font-weights.mjs'), false); + assert.equal(isScannedFile('libs/ui/styles/README.md'), false); +}); + +test('fails instead of passing when the scan finds no stylesheets', () => { + assert.match(validateScanCoverage([])[0], /No stylesheets were scanned/); + assert.match( + validateScanCoverage(['apps/web/src/app/app.component.ts'])[0], + /No stylesheets were scanned/ + ); + assert.deepEqual(validateScanCoverage(['apps/web/src/styles.scss']), []); +}); diff --git a/tools/nx/check-stylesheet-inputs.mjs b/tools/nx/check-stylesheet-inputs.mjs index 8e009f1f3..4009db0bd 100644 --- a/tools/nx/check-stylesheet-inputs.mjs +++ b/tools/nx/check-stylesheet-inputs.mjs @@ -4,7 +4,14 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const STYLESHEET_RULE = /@(use|forward|import)\s+([^;{}]*)/g; +const STYLESHEET_RULE = /@(use|forward|import)\s+/g; +const OPEN_URL = /url\(\s*[^\s)'"]*$/i; + +/** Whether `index` sits in an unquoted `url(…)` on its line. */ +function inUrl(source, index) { + const lineStart = source.lastIndexOf('\n', index - 1) + 1; + return OPEN_URL.test(source.slice(lineStart, index)); +} const QUOTED_TARGET = /(['"])([^'"]+)\1/g; const CSS_URL = /url\([^)]*\)/g; @@ -27,32 +34,123 @@ function targetsOfRule(rule, clause) { /** * Sass documents relative `@use` examples inside comments. Those paths do not * resolve from the file that documents them, so scanning raw source reports - * them as broken imports. + * them as broken imports. A comment marker inside a string is text + * (`with ($asset: '//cdn/x')`), and so is a `//` in an unquoted + * `url(https://…)`; anywhere else, even right after `:` or `(`, `//` opens + * a comment. */ export function stripScssComments(source) { - const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, ' '); - return withoutBlocks - .split('\n') - .map((line) => { - const commentStart = line.search(/(^|[^:])\/\//); - if (commentStart === -1) return line; - return line.slice( - 0, - line[commentStart] === '/' ? commentStart : commentStart + 1 - ); - }) - .join('\n'); + // Blank rather than cut, so every offset still points into `source`. + const out = source.split(''); + const blank = (from, to) => { + for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' '; + return to - 1; + }; + let quote = ''; + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote || char === '\n') quote = ''; + } else if (char === '"' || char === "'") { + quote = char; + } else if (source.startsWith('/*', i)) { + const end = source.indexOf('*/', i + 2); + i = blank(i, end === -1 ? source.length : end + 2); + } else if (source.startsWith('//', i) && !inUrl(source, i)) { + const end = source.indexOf('\n', i); + i = blank(i, end === -1 ? source.length : end); + } + } + return out.join(''); +} + +/** + * Where a rule's clause ends: at a `;`, `{` or `}` outside a string and a + * Sass interpolation, so a quoted `;` in a configuration + * (`'data:image/svg+xml;utf8,…'`) and `#{600}` are values. + */ +function clauseEnd(text, start) { + let quote = ''; + let interpolation = 0; + for (let i = start; i < text.length; i += 1) { + const char = text[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote || char === '\n') quote = ''; + } else if (char === '"' || char === "'") { + quote = char; + } else if (text.startsWith('#{', i)) { + interpolation += 1; + i += 1; + } else if (char === '}' && interpolation > 0) { + interpolation -= 1; + } else if (';{}'.includes(char)) { + return i; + } + } + return text.length; +} + +/** + * Every `@use`/`@forward`/`@import` target, with the rule that loads it, any + * `as` clause (`as t`, `as *`, or a `@forward … as btn-*` prefix), a + * `@forward`'s `show`/`hide` member list (`filter`, or `null`), where the + * rule starts (`index`) and where its `with (…)` configuration sits in + * `source` (`[start, end)`, or `null`). + */ +export function extractStylesheetLoads(source) { + const loads = []; + const stripped = stripScssComments(source); + for (const match of stripped.matchAll(STYLESHEET_RULE)) { + const rule = match[1]; + const clauseStart = match.index + match[0].length; + const clause = stripped.slice( + clauseStart, + clauseEnd(stripped, clauseStart) + ); + const rest = clause.replace(QUOTED_TARGET, (quoted) => + ' '.repeat(quoted.length) + ); + const as = + rule === 'import' + ? null + : (/\bas\s+(\*|[\w-]+\*?)/.exec(rest)?.[1] ?? null); + const opening = /\bwith\s*\(/.exec(rest); + const configuration = opening + ? [ + clauseStart + opening.index + opening[0].length, + clauseStart + rest.lastIndexOf(')'), + ] + : null; + // The list ends where a `with (…)` starts; its values are not names. + const listed = /\b(show|hide)\s+([\s\S]*)/.exec( + rest.slice(0, opening?.index ?? rest.length) + ); + const filter = + rule === 'forward' && listed + ? { + kind: listed[1], + names: listed[2] + .split(',') + .map((name) => name.trim()) + .filter(Boolean), + } + : null; + for (const target of targetsOfRule(rule, clause)) { + loads.push({ + ...{ rule, target, as, configuration, filter }, + index: match.index, + }); + } + } + return loads; } export function extractRelativeImports(source) { - const specifiers = []; - const stripped = stripScssComments(source); - for (const [, rule, clause] of stripped.matchAll(STYLESHEET_RULE)) { - for (const target of targetsOfRule(rule, clause)) { - if (target.startsWith('.')) specifiers.push(target); - } - } - return specifiers; + return extractStylesheetLoads(source) + .map(({ target }) => target) + .filter((target) => target.startsWith('.')); } /** Mirrors Sass partial resolution for a relative specifier. */ diff --git a/tools/nx/check-stylesheet-inputs.test.mjs b/tools/nx/check-stylesheet-inputs.test.mjs index c511a3732..a30260f15 100644 --- a/tools/nx/check-stylesheet-inputs.test.mjs +++ b/tools/nx/check-stylesheet-inputs.test.mjs @@ -4,6 +4,7 @@ import { test } from 'node:test'; import { extractRelativeImports, + extractStylesheetLoads, resolveStylesheet, stripScssComments, validateScanCoverage, @@ -159,6 +160,30 @@ test('treats a @use configuration value as a value, not a second import', () => ]); }); +test('reads the show or hide list of a @forward', () => { + const source = [ + "@forward 'a' as p-* hide $p-w, mixin-x;", + "@forward 'b' show $w with ($w: 600);", + "@forward 'd' with ($mode: hide auto);", + "@forward 'show-tokens' as show-*;", + "@use 'c' as show;", + ].join('\n'); + + assert.deepEqual( + extractStylesheetLoads(source).map(({ target, filter }) => [ + target, + filter, + ]), + [ + ['a', { kind: 'hide', names: ['$p-w', 'mixin-x'] }], + ['b', { kind: 'show', names: ['$w'] }], + ['d', null], + ['show-tokens', null], + ['c', null], + ] + ); +}); + test('ignores a url() import the browser resolves at runtime', () => { assert.deepEqual(extractRelativeImports('@import url("./plain.css");'), []); }); @@ -172,6 +197,48 @@ test('keeps protocol slashes intact when stripping line comments', () => { assert.doesNotMatch(stripped, /trailing note/); }); +test('keeps comment markers inside strings', () => { + const source = [ + "@use 'tokens' with ($asset: '//cdn/x', $w: 600); // note", + '/* a */ $b: "/* kept */";', + ].join('\n'); + const stripped = stripScssComments(source); + + assert.equal(stripped.length, source.length); + assert.match(stripped, /'\/\/cdn\/x', \$w: 600\);/); + assert.match(stripped, /"\/\* kept \*\/"/); + assert.doesNotMatch(stripped, /note|\/\* a/); + const [{ configuration }] = extractStylesheetLoads(source); + assert.equal(source.slice(...configuration), "$asset: '//cdn/x', $w: 600"); + // A quoted `;` is a value, not the rule's end. + const dataUri = + "@use 'tokens' with ($asset: 'data:image/svg+xml;utf8,x', $w: 600);"; + const [{ configuration: range }] = extractStylesheetLoads(dataUri); + assert.equal( + dataUri.slice(...range), + "$asset: 'data:image/svg+xml;utf8,x', $w: 600" + ); + // So is a Sass interpolation's `}`. + const interpolated = "@use 'tokens' with ($w: #{600}, $h: #{$w});"; + const [{ configuration: span }] = extractStylesheetLoads(interpolated); + assert.equal(interpolated.slice(...span), '$w: #{600}, $h: #{$w}'); + // An escaped quote stays inside the string, an unclosed one ends at the + // line break, and an unquoted URL keeps its slashes. + for (const [text, kept, dropped] of [ + ["$a: 'it\\'s // kept'; // gone", /\/\/ kept/, /gone/], + ["$a: 'open\n// gone", /open/, /gone/], + ['$a: url(//cdn/x.css); // gone', /url\(\/\/cdn/, /gone/], + ['$a: url(https://cdn/x.css); // gone', /https:\/\/cdn/, /gone/], + ['$a:// gone', /\$a:/, /gone/], + ['// a note on url(\n// gone', /\n/, /gone/], + ['$a: (// gone\n1);', /\$a: \(/, /gone/], + ]) { + const result = stripScssComments(text); + assert.match(result, kept); + assert.doesNotMatch(result, dropped); + } +}); + test('resolves a specifier to its Sass partial file', () => { const existing = new Set([ path.resolve('/repo/libs/ui/styles/_detail-view.scss'), diff --git a/tools/nx/font-weight-family.mjs b/tools/nx/font-weight-family.mjs new file mode 100644 index 000000000..2732c64dd --- /dev/null +++ b/tools/nx/font-weight-family.mjs @@ -0,0 +1,1580 @@ +/** + * Which stylesheet rules render in JetBrains Mono, for the weight cap in + * `check-font-weights.mjs`. + */ + +import { inConditionPrelude, tokensOf } from './font-weight-lexer.mjs'; + +/** JetBrains Mono is bundled at 400 and 500 only (see `styles.scss`). */ +export const MONO_WEIGHT_CAP = 500; +/** + * The bundled face's name, matched whole (`'Not JetBrains Mono'` is not + * it) after `familyName` reads the entry. + */ +export const MONO_FAMILY = /^jetbrains mono$/i; + +const FONT_FAMILY = /(? b.start === scope); + if (!block || !/^font\s*:/i.test(block.prelude)) return undefined; + return scopes[1] ?? null; +} +/** 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 declaration's whole value, to its `;`: a family list is comma-separated. + * A Sass `#{…}` (or template `${…}`) interpolation is part of it; `selector` + * means a `{` came first. + */ +export function declarationText({ text, quoteAt }, start) { + let end = start; + let interpolation = 0; + for (; end < text.length; end += 1) { + if (quoteAt[end]) continue; + if ('#$'.includes(text[end]) && text[end + 1] === '{') { + interpolation += 1; + end += 1; + } else if (text[end] === '}' && interpolation > 0) { + interpolation -= 1; + } else if (';{}'.includes(text[end])) { + break; + } + } + return { value: text.slice(start, end), selector: text[end] === '{' }; +} + +/** + * A `font` size, alone or with its `/line-height`: a length or percentage + * (a unitless number is a weight, `0` aside), a keyword or a function. + */ +const FONT_SIZE = + /^(?:[+-]?(?:\d+\.?\d*|\.\d+)(?:[a-z]+|%)|0|(?:xx?x?-)?(?:small|large)|medium|smaller|larger|[a-z-]+\(.*\))(?:\/.*)?$/i; +/** `font` values that parse without a size and a family. */ +const FONT_KEYWORD = + /^(?:inherit|initial|unset|revert|revert-layer|caption|icon|menu|message-box|small-caption|status-bar)$/i; + +/** + * Whether a `font` shorthand parses, and so replaces the rule's earlier + * weight: a keyword, or a size followed by a family. One with `var()` or a + * Sass value is only checked once substituted, so it counts (at computed + * time an invalid one inherits the weight instead). + */ +export function parsesAsFont(value) { + const text = value.replace(IMPORTANT, '').trim(); + if (/var\(|\$|#\{/.test(text) || FONT_KEYWORD.test(text)) return true; + const tokens = tokensOf(text); + const size = tokens.findIndex((token) => FONT_SIZE.test(token)); + const family = tokens + .slice(size + 1) + .some((token) => token !== '/' && !/^[\d.]/.test(token)); + return size !== -1 && family; +} + +/** + * Families that render every glyph the UI shows: Roboto (bundled with + * Latin, Cyrillic and Greek) and the text generics. DM Sans is Latin-only, + * and `emoji`, `math` or `fangsong` cover special scripts, so ordinary + * Russian or Greek text falls through them to the next family. A generic + * is a keyword: quoted (`'monospace'`), it names a family nobody has. + */ +const BUNDLED = /^roboto$/i; +const GENERIC = /^(?:serif|sans-serif|monospace|cursive|fantasy|system-ui)$/i; + +/** + * A CSS escape at `text[i]` (a `\`): what it stands for and how many + * characters it spans. Up to six hex digits (and one whitespace after them) + * give a code point; a line break continues a string; any other character + * stands for itself. `null` when it cannot appear here (a line break + * outside a string). + */ +function escapeAt(text, i, quoted) { + const hex = /^([\da-f]{1,6})(?:\r\n|[ \t\r\n\f])?/i.exec(text.slice(i + 1)); + if (hex) { + // Past the last code point it is U+FFFD (`fromCodePoint` throws). + const code = Number.parseInt(hex[1], 16); + const char = code > 0x10ffff ? '\uFFFD' : String.fromCodePoint(code); + return [char, hex[0].length + 1]; + } + const lineBreak = /^(?:\r\n|[\n\r\f])/.exec(text.slice(i + 1)); + if (lineBreak) return quoted ? ['', lineBreak[0].length + 1] : null; + return [text[i + 1] ?? '', 2]; +} + +/** + * The family an entry names, as the browser reads it: a string's text, or + * its identifiers joined by single spaces, escapes decoded (`\4a etBrains + * Mono`, `JetBrains\ Mono`). `quoted` says it was a string; `null` when + * the entry is not one name (`'JetBrains' Mono`, an unclosed string). + */ +function familyName(entry) { + const text = entry.trim(); + const quote = /^['"]/.test(text) ? text[0] : ''; + const words = []; + let word = ''; + for (let i = quote ? 1 : 0; i < text.length; i += 1) { + const char = text[i]; + if (char === '\\') { + const escape = escapeAt(text, i, Boolean(quote)); + if (!escape) return null; + word += escape[0]; + i += escape[1] - 1; + } else if (quote && char === quote) { + return i === text.length - 1 ? { name: word, quoted: true } : null; + } else if (!quote && /\s/.test(char)) { + if (word) words.push(word); + word = ''; + } else { + word += char; + } + } + if (quote) return null; + return { name: [...words, word].filter(Boolean).join(' '), quoted: false }; +} + +/** + * Whether a `font` shorthand has a literal size, so a variable after it + * holds family names rather than the whole shorthand. + */ +export function hasLiteralSize(value) { + return tokensOf(value).some( + (token) => FONT_SIZE.test(token) && !/^var\(/i.test(token) + ); +} + +/** What can come before a shorthand's size: weight, style, variant, width. */ +const SHORTHAND_PREFIX = + /^(?:\d+|normal|italic|oblique|small-caps|bold|bolder|lighter|(?:ultra-|extra-|semi-)?(?:condensed|expanded))$/i; + +/** + * An operand of a static Sass interpolation: a string (its escapes kept, + * for the name to decode as it does its own), word or number. + */ +const STATIC_OPERAND = String.raw`(?:'(?:[^'\\]|\\[\s\S])*'|"(?:[^"\\]|\\[\s\S])*"|[a-z_][\w-]*|\d[\w.%]*)`; + +const STATIC_INTERPOLATION = new RegExp( + String.raw`#\{\s*(${STATIC_OPERAND}(?:\s*\+?\s*${STATIC_OPERAND})*)\s*\}`, + 'gi' +); + +/** + * Text with each static Sass interpolation written out as Sass does: + * strings unquoted, operands spaced or joined by `+`, `null` as nothing + * (`#{'Jet' + 'Brains'} Mono` is `JetBrains Mono`). One that reads a + * variable or calls a function stays, for its value to decide. + */ +export function staticInterpolated(text) { + return text.replace(STATIC_INTERPOLATION, (match, expression) => { + const values = []; + // Operands a `+` joins concatenate; others are spaced, as a list. + let joined = false; + const operand = new RegExp(STATIC_OPERAND, 'iy'); + for (let i = 0; i < expression.length;) { + if (/\s/.test(expression[i])) { + i += 1; + continue; + } + if (expression[i] === '+') { + joined = true; + i += 1; + continue; + } + operand.lastIndex = i; + const [token] = operand.exec(expression); + const value = /^['"]/.test(token) + ? token.slice(1, -1) + : /^null$/i.test(token) + ? '' + : token; + if (joined && values.length) values[values.length - 1] += value; + else values.push(value); + joined = false; + i += token.length; + } + return values.filter(Boolean).join(' '); + }); +} + +/** + * One family entry, decided outright: `mono` (JetBrains Mono), `stop` (a + * family that renders every glyph), `open` (another face), or `null` for a + * variable (`var(…)`, `$x`, `#{…}`) whose value decides. A static `#{…}` + * reads as the text it writes out. + */ +export function entryVerdict(written) { + const entry = staticInterpolated(written); + // In a string, only a Sass `#{…}` is a variable: `"var(--x)"` and + // `"$x"` are names. + const quoted = /^\s*['"]/.test(entry); + if (quoted ? entry.includes('#{') : /var\(|\$|#\{/i.test(entry)) { + return null; + } + const family = familyName(entry); + if (!family) return 'open'; + if (MONO_FAMILY.test(family.name)) return 'mono'; + if (BUNDLED.test(family.name)) return 'stop'; + if (!family.quoted && GENERIC.test(family.name)) return 'stop'; + return 'open'; +} + +/** A family list's entries, split at its top-level commas. */ +export function familyEntries(list) { + return selectorsOf(list.replace(IMPORTANT, '')); +} + +/** + * The family list of a `font` shorthand: what follows its size and + * `/line-height`, or `null` when it has no size. + */ +export function shorthandFamilies(value) { + const tokens = tokensOf(value.replace(IMPORTANT, '')); + // A literal size first; `var()` (or its placeholder) only when none. + const size = tokens.findIndex( + (token) => FONT_SIZE.test(token) && !/^var\(/i.test(token) + ); + // Without one, the family starts at the first literal name or entry + // that a comma ends, else at the last token, and the size is the token + // before it (`var(--weight) var(--size) 'JetBrains Mono'`). + if (size === -1) { + let start = tokens.findIndex( + (token, i) => + i > 0 && + (token.endsWith(',') || + tokens[i + 1] === ',' || + (!/^[\d./]/.test(token) && + !SHORTHAND_PREFIX.test(token) && + entryVerdict(token) !== null)) + ); + if (start === -1) start = tokens.length - 1; + return start < 1 ? null : tokens.slice(start).join(' '); + } + let rest = tokens.slice(size + 1); + if (rest[0] === '/') rest = rest.slice(2); + else if (rest[0]?.startsWith('/')) rest = rest.slice(1); + return rest.join(' '); +} + +/** + * Whether JetBrains Mono can render a family list: it is named before any + * family that always resolves (a bundled face such as Roboto, or an + * unquoted generic such as `monospace`). A face only some systems have (`ui-monospace`, + * `'SF Mono'`) leaves it in play. In a `font` shorthand the list follows + * the size and its `/line-height`. + */ +export function rendersMono( + written, + { shorthand = false, defer = false } = {} +) { + const value = staticInterpolated(written); + const list = shorthand ? shorthandFamilies(value) : value; + if (list === null) return false; + for (const entry of familyEntries(list)) { + const verdict = entryVerdict(entry.replace(/\bvar\(\)/g, 'var(')); + // A variable decides in its place; with `defer`, leave it to the + // workspace-wide resolution rather than skip past it. + if (verdict === null) { + if (defer) return false; + continue; + } + if (verdict !== 'open') return verdict === 'mono'; + } + return false; +} + +/** Where the string opening at `value[start]` ends (past its quote). */ +function stringEnd(value, start) { + for (let i = start + 1; i < value.length; i += 1) { + if (value[i] === '\\') i += 1; + else if (value[i] === value[start]) return i + 1; + } + return value.length; +} + +/** + * A family value split into what it names outright (`outside`) and the + * custom properties it reads (`vars`), each with the fallback that applies + * only where the property is never set: `var(--face, 'JetBrains Mono')`. + */ +export function familyParts(value) { + const vars = []; + let outside = ''; + let i = 0; + while (i < value.length) { + // A string is a name, whatever it spells (`"var(--x)"`). + if (value[i] === '"' || value[i] === "'") { + const end = stringEnd(value, i); + outside += value.slice(i, end); + i = end; + continue; + } + const open = /^var\(\s*(--[\w-]+)\s*(,)?/i.exec(value.slice(i)); + if (!open) { + outside += value[i]; + i += 1; + continue; + } + let end = i + open[0].length; + for (let depth = 1; end < value.length && depth > 0; end += 1) { + if (value[end] === '"' || value[end] === "'") { + end = stringEnd(value, end) - 1; + } else if (value[end] === '(') depth += 1; + else if (value[end] === ')') depth -= 1; + } + const fallback = open[2] + ? value.slice(i + open[0].length, end - 1) + : null; + vars.push({ name: open[1], fallback }); + // A placeholder keeps the token's place (a shorthand's size). + outside += 'var()'; + i = end; + } + return { outside, vars }; +} + +/** + * A selector (or family) list split at its top-level commas: `:is(a, b)`, + * `'a, b'` and `a\, b` stay whole. + */ +export function selectorList(prelude) { + return selectorsOf(prelude); +} + +function selectorsOf(prelude) { + const selectors = []; + let depth = 0; + let quote = ''; + let current = ''; + for (let i = 0; i < prelude.length; i += 1) { + const char = prelude[i]; + // An escaped character, or one in a string, is text. + if (char === '\\') { + current += prelude.slice(i, i + 2); + i += 1; + continue; + } + if (quote) { + if (char === quote) quote = ''; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + } + if (char === ',' && depth === 0 && !quote) { + selectors.push(current.trim()); + current = ''; + } else { + current += char; + } + } + return [...selectors, current.trim()]; +} + +/** + * How near the elements a reading rule styles a rule that reaches every + * element sets its custom properties: `:root`/`html` at the top (1), `body` + * below it (2), `*` on each element itself (4); `null` for any other rule (a + * component's `:host` reaches only its own view). `body` sits below `html`, + * so it cannot pass a property up to a `:root` reader. + */ +export function reachDepth(prelude, reader = null) { + if (!prelude) return null; + // One that targets the root element (`html.dark`, `:root:not(.x)`). + const rootReader = reader + ? rootSelectors(reader).some((s) => + /^(?::root|html)(?![\w-])/i.test( + compoundsOf(s).at(-1)?.compound ?? '' + ) + ) + : false; + const depths = rootSelectors(prelude).map((selector) => { + if (selector === '*') return 4; + if (/^body$/i.test(selector)) return rootReader ? null : 2; + return /^(?::root|html)$/i.test(selector) ? 1 : null; + }); + const reached = depths.filter((depth) => depth !== null); + return reached.length ? Math.max(...reached) : null; +} + +/** Whether a rule's custom properties reach every element (`reachDepth`). */ +export function reachesEverything(prelude, reader = null) { + return reachDepth(prelude, reader) !== null; +} + +/** + * A selector's compounds, each with the combinator before it (`.a .b > .c` + * is `.a`, ` ` `.b`, `>` `.c`), split outside brackets, parentheses, + * strings and escapes. + */ +function compoundsOf(selector) { + const parts = []; + let compound = ''; + let combinator = ''; + let depth = 0; + let quote = ''; + for (let i = 0; i < selector.length; i += 1) { + const char = selector[i]; + if (char === '\\') { + compound += selector.slice(i, i + 2); + i += 1; + } else if (quote || depth > 0 || !/[\s>+~]/.test(char)) { + if (quote && char === quote) quote = ''; + else if (!quote && (char === '"' || char === "'")) quote = char; + else if (!quote && '(['.includes(char)) depth += 1; + else if (!quote && ')]'.includes(char)) depth -= 1; + compound += char; + } else { + // Whitespace alone is the descendant combinator. + if (compound) parts.push({ combinator, compound }); + if (compound) combinator = ' '; + if (!/\s/.test(char)) combinator = char; + compound = ''; + } + } + if (compound) parts.push({ combinator, compound }); + return parts; +} + +/** Selector text from its compounds, spaced one way (`.a>.b` is `.a > .b`). */ +function joined(parts) { + return parts + .map(({ combinator, compound }, k) => { + if (combinator === ' ') return ` ${compound}`; + if (!combinator) return compound; + return `${k ? ' ' : ''}${combinator} ${compound}`; + }) + .join(''); +} + +/** + * One simple selector: a type or `*`, class, id, attribute or pseudo (a + * functional one's argument read to its balanced `)`, however nested). + */ +const SIMPLE = + /\*|[a-z][\w-]*|\.(?:\\.|[\w-])+|#(?:\\.|[\w-])+|\[[^\]]*\]|::?[\w-]+/iy; + +/** Where the `(` at `open` closes (past its `)`), or -1; strings skipped. */ +function closingParen(text, open) { + let depth = 0; + let quote = ''; + for (let i = open; i < text.length; i += 1) { + const char = text[i]; + if (char === '\\') i += 1; + else if (quote) { + if (char === quote) quote = ''; + } else if (char === '"' || char === "'") quote = char; + else if (char === '(') depth += 1; + else if (char === ')' && (depth -= 1) === 0) return i + 1; + } + return -1; +} + +/** + * A compound's simple selectors (`a.x:hover` is `a`, `.x`, `:hover`), or + * `null` for one this cannot split: a Sass `&` or interpolation, or more + * than one compound. + */ +function simplesOf(compound) { + const simples = []; + SIMPLE.lastIndex = 0; + while (SIMPLE.lastIndex < compound.length) { + const start = SIMPLE.lastIndex; + const match = SIMPLE.exec(compound); + if (!match) return null; + if (match[0].startsWith(':') && compound[SIMPLE.lastIndex] === '(') { + const end = closingParen(compound, SIMPLE.lastIndex); + if (end === -1) return null; + SIMPLE.lastIndex = end; + } + simples.push(compound.slice(start, SIMPLE.lastIndex)); + } + return simples; +} + +const ATTRIBUTE = + /^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\s'"\]]+))\s*)?\]$/; + +/** + * A simple selector as the elements it matches, spelled one way for + * comparing (not ranking): a type lowercased, an attribute's quotes and + * spaces dropped, and `[class~=x]` read as `.x` and `[id=x]` as `#x`. A + * type is lowercased even for SVG (`foreignObject`): Chromium, the app's + * Electron runtime, matches it so in an HTML document, where WebKit and + * Firefox match an SVG element's case exactly. + */ +function matchKey(simple) { + const attribute = ATTRIBUTE.exec(simple); + if (!attribute) { + return /^[a-z][\w-]*$/i.test(simple) ? simple.toLowerCase() : simple; + } + const [, written, operator, double, single, bare] = attribute; + const name = written.toLowerCase(); + if (!operator) return `[${name}]`; + const value = double ?? single ?? bare; + const word = /^[\w-]+$/.test(value); + if (word && name === 'class' && operator === '~=') return `.${value}`; + if (word && name === 'id' && operator === '=') return `#${value}`; + return `[${name}${operator}${JSON.stringify(value)}]`; +} + +/** A functional pseudo-class and its argument (`:is(.x, .y)`). */ +const FUNCTIONAL = /^:([\w-]+)\(([\s\S]*)\)$/; + +/** + * A selector's specificity as `[ids, classes, types]`: `:where()` counts + * nothing, `:is()`, `:not()` and `:has()` their most specific argument. + */ +export function specificityOf(selector) { + const total = [0, 0, 0]; + for (const { compound } of compoundsOf(selector)) { + for (const simple of simplesOf(compound) ?? []) { + const [, name, argument] = FUNCTIONAL.exec(simple) ?? []; + let add = [0, 0, 0]; + const of = /^nth-(?:last-)?child$/i.test(name ?? '') + ? /\sof\s+([\s\S]+)$/i.exec(argument)?.[1] + : undefined; + if (/^(?:is|not|has|matches)$/i.test(name ?? '') || of) { + add = selectorsOf(of ?? argument) + .map(specificityOf) + .reduce((a, b) => (compare(a, b) >= 0 ? a : b), [0, 0, 0]); + // `:nth-child(… of S)` is a pseudo-class as well as `S`. + if (of) add = [add[0], add[1] + 1, add[2]]; + } else if (/^where$/i.test(name ?? '') || simple === '*') { + add = [0, 0, 0]; + } else if (simple.startsWith('#')) add = [1, 0, 0]; + else if ( + /^::|^:(?:before|after|first-line|first-letter)$/i.test(simple) + ) { + add = [0, 0, 1]; + } else if (/^[.:[]/.test(simple)) add = [0, 1, 0]; + else add = [0, 0, 1]; + for (let k = 0; k < 3; k += 1) total[k] += add[k]; + } + } + return total; +} + +/** A declaration's importance: an animation's outranks a normal one. */ +const IMPORTANT_LEVEL = 2; +export function levelOf(declaration) { + if (!declaration) return -1; + if (declaration.important) return IMPORTANT_LEVEL; + return declaration.animated ? 1 : 0; +} + +/** Whether cascade rank `a` beats `b` (a later equal rank wins). */ +function rankAbove(a, b) { + if (a.important !== b.important) return a.important > b.important; + const layer = ordered(a.layer, b.layer); + if (layer) return a.important === IMPORTANT_LEVEL ? layer < 0 : layer > 0; + const specificity = compare(a.specificity, b.specificity); + if (specificity) return specificity > 0; + return a.order > b.order; +} + +/** + * How two layer places compare, level by level: each ends in `Infinity`, + * so two that differ do so before the shorter one ends. + */ +function ordered(a, b) { + for (let k = 0; k < Math.min(a.length, b.length); k += 1) { + if (a[k] !== b[k]) return a[k] > b[k] ? 1 : -1; + } + return 0; +} + +/** Which of two specificities is greater (positive), equal (0) or less. */ +function compare(a, b) { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +/** + * The ways a compound's simple selectors can be read with `:is()` and + * `:where()` opened (`:is(.x, .y).z` is `.x .z` or `.y .z`), up to 16. + */ +function alternativesOf(simples) { + let ways = [[]]; + for (const simple of simples) { + const [, name, argument] = FUNCTIONAL.exec(simple) ?? []; + // Each argument opens in turn (`:is(:where(.x))` is `.x`). + const options = /^(?:is|where|matches)$/i.test(name ?? '') + ? selectorsOf(argument).flatMap((s) => { + const inner = simplesOf(s); + return inner ? alternativesOf(inner) : [null]; + }) + : [[simple]]; + if (options.some((option) => !option)) return [simples]; + ways = ways.flatMap((way) => options.map((o) => [...way, ...o])); + if (ways.length > 16) return [simples]; + } + return ways; +} + +/** + * The complex selectors a selector reads as with its `:is()`/`:where()` + * opened, in turn and up to 16: in any compound when they hold compounds + * (`.x :where(.p) .c` is `.x .p .c`), and alone as a first compound or + * after a descendant combinator also when they hold whole selectors + * (`.x :is(.a .b)` reads as `.x .a .b`, elements it matches all); after + * `>`, `+` or `~` a whole selector would name others, so it stays. + */ +function complexOf(selector) { + let ways = [[]]; + for (const [k, part] of compoundsOf(selector).entries()) { + const simples = simplesOf(part.compound); + const spliced = (k === 0 || part.combinator === ' ') && simples; + const whole = spliced ? simples.filter(holdsSelectors) : []; + const options = + whole.length === 1 + ? splicedWith(whole[0], simples, part.combinator) + : (simples ? alternativesOf(simples) : [[part.compound]]).map( + (way) => [{ ...part, compound: way.join('') }] + ); + ways = ways.flatMap((way) => options.map((o) => [...way, ...o])); + if (ways.length > 16) return [canonicalSelector(selector)]; + } + return ways.map(joined); +} + +/** + * Whether a simple is an `:is()`/`:where()` holding a whole selector, also + * through others nested in it (`:where(:is(.p .c))`). + */ +function holdsSelectors(simple) { + const [, name, argument] = FUNCTIONAL.exec(simple) ?? []; + return ( + /^(?:is|where|matches)$/i.test(name ?? '') && + selectorsOf(argument) + .flatMap(complexOf) + .some((s) => compoundsOf(s).length > 1) + ); +} + +/** + * The compounds `simple` (one holding whole selectors) opens into, the + * compound's other simples joining each selector's last compound + * (`:where(.p .c).active` is `.p .c.active`). + */ +function splicedWith(simple, simples, combinator) { + const [, , argument] = FUNCTIONAL.exec(simple); + const rest = alternativesOf(simples.filter((s) => s !== simple)); + return selectorsOf(argument) + .flatMap(complexOf) + .flatMap((inner) => + rest.flatMap((others) => { + const parts = compoundsOf(inner); + const last = parts.at(-1); + const own = simplesOf(last.compound); + if (!own) return []; + // A type leads the compound, and `*` gives way to the rest. + const merged = [...new Set([...own, ...others])]; + const all = + merged.length > 1 + ? merged.filter((s) => s !== '*') + : merged; + const types = all.filter((s) => /^[a-z][\w-]*$/i.test(s)); + const compound = [ + ...types, + ...all.filter((s) => !types.includes(s)), + ].join(''); + const spliced = [...parts.slice(0, -1), { ...last, compound }]; + return [spliced.map((c, i) => (i ? c : { ...c, combinator }))]; + }) + ); +} + +/** + * An at-rule prelude spaced and cased one way, so conditions written + * differently compare equal (`@MEDIA (min-width:1px)` is `@media + * (min-width: 1px)`): its name lowercased, no space around `(`, `)`, `:` + * or `,`, and one space elsewhere; a quoted string kept as written. + */ +function conditionKey(prelude) { + return prelude + .replace(/^@[\w-]+/, (name) => name.toLowerCase()) + .split(/("(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*')/) + .map((part, k) => + k % 2 + ? part + : part.replace(/\s+/g, ' ').replace(/\s*([():,])\s*/g, '$1') + ) + .join('') + .trim(); +} + +/** + * Whether a rule in context `outer` applies wherever one in `inner` does: + * each of its conditions (at-rule wrappers, `@if`/`@each` blocks) is one + * of the inner one's too (an unconditional rule applies everywhere). + */ +function encloses(outer, inner) { + const parts = (context) => + context.split(' | ').map((list, k) => { + if (!list) return []; + return list.split(k === 0 ? ' ; ' : ' '); + }); + const [a, b] = [parts(outer), parts(inner)]; + return a.every((list, k) => list.every((part) => b[k].includes(part))); +} + +/** A selector spaced one way, so `.a>.b` and `.a > .b` compare equal. */ +export function canonicalSelector(selector) { + return joined(compoundsOf(selector)); +} + +/** + * The ancestors a selector names for the element it targets, nearest + * first: `.a .b > .c` has `.a .b` and `.a`. A compound followed by `+` or + * `~` is a sibling, so `.a .b + .c` has only `.a`. + */ +export function ancestorsOf(selector) { + const parts = compoundsOf(selector); + const ancestors = []; + for (let i = parts.length - 2; i >= 0; i -= 1) { + if (/^[ >]$/.test(parts[i + 1].combinator)) { + ancestors.push(joined(parts.slice(0, i + 1))); + } + } + return ancestors; +} + +/** A selector list with `:where(…)` and `:is(…)` wrappers opened. */ +function rootSelectors(prelude) { + return selectorsOf(prelude).flatMap((selector) => { + const wrapped = /^:(?:where|is)\((.*)\)$/i.exec(selector); + return wrapped ? rootSelectors(wrapped[1]) : [selector]; + }); +} + +/** + * Whether a rule is a component's plain `:host`, which reaches its whole + * view; `:host(.light)` applies only in that state. + */ +export function plainHost(prelude) { + if (!prelude) return false; + return selectorsOf(prelude).some((selector) => /^:host$/i.test(selector)); +} + +/** + * Whether a nested rule styles its parent's element or a descendant, which + * inherit the parent's family: `&:hover`, `.child`, `> .child`, or a + * `@media` or `@include` body. A `&-suffix` class and a `+`/`~` sibling are + * other elements, unless the sibling is the parent's own kind again (`& + &` + * targets `.x + .x`, an `.x`); any other at-rule (a mixin body included + * elsewhere) is set where it runs. + */ +function inherits(prelude) { + if (prelude.startsWith('@')) return SAME_ELEMENT.test(prelude); + return selectorsOf(prelude).some((selector) => { + const target = selector + .split(/\s*[>+~]\s*|\s+/) + .filter(Boolean) + .at(-1); + if (/^&(?![\w-])/.test(target ?? '')) return true; + return !/^&[\w-]/.test(selector) && !/^&?\s*[+~]/.test(selector); + }); +} + +/** + * What an `@at-root` block leaves out of the blocks around it, by kind + * (`rule` for a style rule, else the at-rule's name): style rules by + * default and for a selector without `&`, nothing for one with `&`, + * those `(without: …)` names (`all` for every block), or all but those + * `(with: …)` names; `null` for another block. + */ +export function atRootExcludes(prelude) { + const atRoot = /^@at-root\b\s*([\s\S]*)$/i.exec(prelude); + if (!atRoot) return null; + const rest = atRoot[1].trim(); + const query = /^\(\s*(with|without)\s*:\s*([^)]*)\)/i.exec(rest); + if (!query) + return rest.includes('&') ? () => false : (kind) => kind === 'rule'; + const names = query[2].trim().toLowerCase().split(/\s+/); + const listed = (kind) => names.includes('all') || names.includes(kind); + return query[1].toLowerCase() === 'without' + ? listed + : (kind) => !listed(kind); +} + +/** A block's kind for `atRootExcludes`: `rule`, or its at-rule's name. */ +export function blockKind(prelude) { + return /^@([\w-]+)/.exec(prelude)?.[1].toLowerCase() ?? 'rule'; +} + +/** + * The selector a block styles: its prelude, or an `@at-root`'s own + * (`@at-root .x`); `null` for any other at-rule. + */ +function styleSelector(block) { + const atRoot = /^@at-root\b\s*([\s\S]*)$/i.exec(block.prelude); + if (atRoot) { + const selector = atRoot[1].trim(); + return selector && !selector.startsWith('(') ? selector : null; + } + return block.prelude.startsWith('@') ? null : block.prelude; +} + +/** An `@extend` of whole selectors (`%mono`, `.a, .b`), `!optional` aside. */ +const EXTEND = /@extend\s+([^;{}!]+?)\s*(?:!\s*optional\s*)?[;}]/gi; + +/** + * The `all` shorthand set to a CSS-wide keyword: it resets the family too, + * to the inherited one (`unset`, `inherit`, `revert`) or the browser's + * default (`initial`). + */ +export const ALL_RESET = + /(?= 0 && /\s/.test(text[k])) k -= 1; + return k < 0 || '{;}'.includes(text[k]); +} + +/** An `@include` of a mixin in this file (`ns.mixin` is another file's). */ +const INCLUDE = /@include\s+([\w-]+)(?![\w.-])/g; +const CONTENT = /@content\b/g; +/** A `@keyframes` block's prelude, with its name. */ +const KEYFRAMES = /^@(?:-[a-z]+-)?keyframes\s+(\S+)/i; +/** An `animation` or `animation-name` declaration. */ +const ANIMATION = /(? name.replace(/^(['"])(.*)\1$/, '$2'); + +/** + * The keyframe names an `animation` (one per layer: the token that is no + * keyword, time, number or function) or `animation-name` value runs; a + * name the scan cannot read (`var(--n)`, `$n`) is `null`, any of them. + */ +function animationNames(value, longhand) { + return selectorsOf(value.replace(IMPORTANT, '')).flatMap((layer) => { + const tokens = layer.trim().split(/\s+/).filter(Boolean); + if (tokens.some((token) => /var\(|\$|#\{/.test(token))) return [null]; + const names = tokens + .map(unquoted) + .filter( + (token) => + longhand || + !( + ANIMATION_KEYWORD.test(token) || + /^[\d.+-]|[(),]/.test(token) + ) + ); + return longhand ? names : names.slice(0, 1); + }); +} + +/** The combinators a selector's one allows across in a narrower one. */ +const ACROSS = { ' ': [' ', '>'], '~': ['~', '+'] }; + +/** + * How two landings (see `landingsOf`) compare in the order Sass writes + * them out: by their place in the rule, then within each mixin and content + * block they go through. + */ +export function keyOrder(a, b) { + for (let k = 0; k < Math.min(a.length, b.length); k += 1) { + if (a[k] !== b[k]) return a[k] - b[k]; + } + return a.length - b.length; +} + +/** + * The family each rule sets (every block with one of its selector chains, + * see `rulesOf`), the last declaration in source order winning unless an + * earlier one is `!important`: whether it names JetBrains Mono outright (`mono`), + * the variables it reads (`refs`, from `familyParts`, resolved later across + * the workspace) or that it inherits (`inherit`/`unset`). Returns + * `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. + */ +export function familiesOf( + lexed, + blocks, + { inString, placeOf, refsIn, rulesOf, transient = true } +) { + const family = new Map(); + // 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(); + for (const match of lexed.text.matchAll(INCLUDE)) { + if (inString(match.index)) continue; + const name = match[1].replace(/_/g, '-'); + if (!includes.has(name)) includes.set(name, []); + includes.get(name).push(match.index); + } + 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) ?? []) + : []; + }; + // A content block (`@include m { … }`) passed to a mixin of this file + // that places `@content` at its top level is the including rule's + // there: its declarations land at the `@include`, in order where each + // `@content` sits in `m`. One placed deeper (in a nested rule or + // `@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 ?? ''); + 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; + }; + // Cascade layers in declared order within their parent layer: as + // `@layer a, b;` names them, or as a `@layer name { … }` block first + // appears; an unnamed block is a layer of its own (`@`). + const layerIndex = new Map(); + const layerCount = new Map(); + const indexIn = (parent, name) => { + const key = `${parent}/${name}`; + if (!layerIndex.has(key)) { + layerIndex.set(key, layerCount.get(parent) ?? 0); + layerCount.set(parent, (layerCount.get(parent) ?? 0) + 1); + } + return layerIndex.get(key); + }; + const layerNameOf = (block) => + block.prelude.replace(/^@layer\s*/i, '').trim() || `@${block.start}`; + const parentLayer = (index) => + blocks + .filter((b) => b.start < index && index < b.end) + .filter((b) => /^@layer\b/i.test(b.prelude)) + .sort((a, b) => a.start - b.start) + .flatMap((b) => layerNameOf(b).split('.')); + const declare = (parent, name) => { + const path = [...parent]; + for (const part of name.split('.')) { + indexIn(path.join('/'), part); + path.push(part); + } + }; + for (const match of lexed.text.matchAll(/@layer\b\s*([^{;]*)([{;])/gi)) { + if (inString(match.index)) continue; + const parent = parentLayer(match.index); + if (match[2] === ';') { + for (const name of match[1].split(',').map((n) => n.trim())) { + if (name) declare(parent, name); + } + } else { + const brace = match.index + match[0].length - 1; + declare(parent, match[1].trim() || `@${brace}`); + } + } + // A layer path to compare level by level, however deep: each level's + // place among its siblings, then an end that ranks a layer's own + // declarations above its sublayers' (and unlayered ones above every + // layer). + const layerPlace = (path) => [ + ...path.map((name, level) => + indexIn(path.slice(0, level).join('/'), name) + ), + Infinity, + ]; + // A rule as compiled: its selector (`.p { &:hover {} }` is `.p:hover`, + // `.w { .p .c {} }` is `.w .p .c`) and the context it applies in (its + // at-rule wrappers and `@if`/`@each`); `null` for one without a style + // rule. One the scan cannot know (`.#{$n}`) stays unique to its block. + const UNCONDITIONAL = ' | '; + const compiled = (rule) => { + const split = rule.lastIndexOf(' | '); + const chain = rule.slice(0, split); + let selector = ''; + let layer = []; + const wrappers = []; + for (const part of chain.split(' < ').reverse()) { + // A cascade layer always applies; it only ranks (see `rankOf`). + if (/^@layer\b/i.test(part)) { + layer = [ + ...layer, + ...part + .replace(/^@layer\s*/i, '') + .trim() + .split('.'), + ]; + } else if (part.startsWith('@')) wrappers.push(conditionKey(part)); + else if (part.includes('&')) + selector = part.replaceAll('&', selector); + else selector = selector ? `${selector} ${part}` : part; + } + if (!selector) return null; + const context = `${wrappers.join(' ; ')} | ${rule.slice(split + 3)}`; + return { selector: canonicalSelector(selector), context, layer }; + }; + // This file's `@extend`s, by the selector they extend: that rule's + // declarations apply to the extending rule too, where they are written. + const extenders = new Map(); + for (const match of lexed.text.matchAll(EXTEND)) { + if (inString(match.index)) continue; + const { scope } = placeOf(blocks, match.index); + const context = compiled(rulesOf(scope)[0] ?? '')?.context; + for (const target of selectorsOf(match[1]).map(canonicalSelector)) { + if (!extenders.has(target)) extenders.set(target, []); + extenders.get(target).push({ index: match.index, scope, context }); + } + } + // Who extends a rule on one compound: from anywhere, or (as Sass allows) + // from inside the same `@media`. + const extendersOf = (scope) => + rulesOf(scope).flatMap((rule) => { + const form = compiled(rule); + if (!form) return []; + return (extenders.get(form.selector) ?? []).filter( + ({ context }) => + context === UNCONDITIONAL || context === form.context + ); + }); + // Where a declaration in `scope` at `at` applies: its own rules, and + // through each `@include` of a mixin (one included in another mixin + // goes on to that one's includes) or `@extend` of a rule, theirs. + // A cycle stops on its own path, so each `@include` of a mixin, two in + // one rule included, is a landing of its own. A landing's `key` is its + // place at each level, outermost first (see `keyOrder`): in the rule, + // then in each mixin included and at each `@content` it goes through. + // Each `@keyframes` name and the block defining it last (a later one + // replaces it), and where a rule's last `animation`/`animation-name` + // runs it (`hold`: it keeps a frame, by `forwards`, `both` or + // `infinite`). A keyframe's declarations apply to that rule's elements + // while it runs, over the rule's own; one that does not hold applies + // only while it runs, so `transient: false` reads the rule after it. + const definedLast = new Map(); + for (const block of blocks) { + const named = KEYFRAMES.exec(block.prelude); + if (named) definedLast.set(unquoted(named[1]), block.start); + } + const runs = new Map([...definedLast.keys()].map((name) => [name, []])); + const lastRun = new Map(); + for (const match of lexed.text.matchAll(ANIMATION)) { + if (inString(match.index) || inConditionPrelude(lexed, match.index)) { + continue; + } + if (!startsDeclaration(lexed.text, match.index)) continue; + const start = match.index + match[0].length; + const { value, selector } = declarationText(lexed, start); + const { scope } = placeOf(blocks, match.index); + if (selector || scope === null || framesOf(scope)) continue; + const longhand = /-name\s*:$/i.test(match[0]); + lastRun.set(scope, { index: match.index, value, longhand }); + } + for (const [scope, { index, value, longhand }] of lastRun) { + const block = blocks.find((b) => b.start === scope); + const own = lexed.text.slice(block.start, block.end); + const hold = HOLDS.test(value) || HELD.test(own); + for (const name of animationNames(value, longhand)) { + const lists = name === null ? [...runs.values()] : [runs.get(name)]; + for (const list of lists) list?.push({ index, hold }); + } + } + // The `@keyframes` a block (one of its steps) sits in, if any. + function framesOf(scope) { + const frame = blocks + .filter((b) => b.start <= scope && scope < b.end) + .find((b) => KEYFRAMES.test(b.prelude)); + if (!frame) return null; + const name = unquoted(KEYFRAMES.exec(frame.prelude)[1]); + return { name, live: definedLast.get(name) === frame.start }; + } + const framesText = (start) => { + const block = blocks.find((b) => b.start === start); + return block ? lexed.text.slice(block.start, block.end) : ''; + }; + // The rules running a keyframe, in the reading `transient` asks for. + const runsOf = (frames) => + frames.live + ? (runs.get(frames.name) ?? []).filter( + (run) => transient || run.hold + ) + : []; + const landingsOf = (scope, at, path = [], inner = []) => { + if (path.includes(scope)) return []; + const next = [...path, scope]; + const key = [at, ...inner]; + // 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 }, + ...runsOf(frames).flatMap(({ index: site }) => + landingsOf( + placeOf(blocks, site).scope, + site, + next, + key + ).map((landing) => ({ ...landing, animated: true })) + ), + ]; + } + const content = contentOf(scope); + if (content) { + const { places, site } = content; + return places.flatMap((place) => + landingsOf(placeOf(blocks, site).scope, site, next, [ + place, + ...key, + ]) + ); + } + return [ + { at, scope, rules: rulesOf(scope), key }, + ...sitesOf(scope).flatMap((site) => + landingsOf(placeOf(blocks, site).scope, site, next, key) + ), + ...extendersOf(scope).flatMap((extender) => + landingsOf(extender.scope, at, next, inner) + ), + ]; + }; + // Each declaration, where it applies, in source order. + const applied = []; + for (const match of lexed.text.matchAll(FONT_FAMILY)) { + if (inString(match.index) || inConditionPrelude(lexed, match.index)) { + continue; + } + if (!startsDeclaration(lexed.text, match.index)) continue; + const start = match.index + match[0].length; + const { value, selector } = declarationText(lexed, start); + const place = placeOf(blocks, match.index); + if (selector || place.scope === null) continue; + // A nested `font: { family: … }` sets its rule's family. + const namespace = fontNamespaceRule(blocks, place); + const property = match[1].toLowerCase(); + if (property === 'family' && namespace === undefined) continue; + // A shorthand that fails to parse is dropped, family and all. + if (property === 'font' && !parsesAsFont(value)) continue; + const important = IMPORTANT.test(value); + const parts = familyParts(value); + const shorthand = property === 'font'; + const entry = { + important, + inherit: /^\s*(?:inherit|unset|revert|revert-layer)\b/i.test(value), + revertLayer: /^\s*revert-layer\b/i.test(value), + mono: rendersMono(parts.outside, { shorthand, defer: true }), + shorthand, + refs: refsIn(parts, match.index, place), + // The whole family and where it sits, to resolve it later. + text: value, + at: { index: match.index, ...place }, + }; + for (const landing of landingsOf( + namespace ?? place.scope, + match.index + )) { + applied.push({ + ...landing, + entry: landing.animated ? { ...entry, animated: true } : entry, + }); + } + } + for (const match of lexed.text.matchAll(ALL_RESET)) { + if (inString(match.index) || inConditionPrelude(lexed, match.index)) { + continue; + } + if (!startsDeclaration(lexed.text, match.index)) continue; + const place = placeOf(blocks, match.index); + if (place.scope === null) continue; + const entry = { + important: Boolean(match[2]), + inherit: match[1].toLowerCase() !== 'initial', + revertLayer: match[1].toLowerCase() === 'revert-layer', + ...{ mono: false, shorthand: false, refs: [], text: match[1] }, + at: { index: match.index, ...place }, + }; + for (const landing of landingsOf(place.scope, match.index)) { + applied.push({ + ...landing, + entry: landing.animated ? { ...entry, animated: true } : 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. + const orderOf = new Map(); + for (const { rules, entry, at } of applied) { + for (const rule of rules) { + if (levelOf(family.get(rule)) > levelOf(entry)) continue; + family.set(rule, entry); + orderOf.set(rule, at); + } + } + // Every rule's family with its compiled selector and context. + const compiledRules = []; + for (const [rule, entry] of family) { + const form = compiled(rule); + if (form) { + const alternatives = complexOf(form.selector); + compiledRules.push({ rule, entry, ...form, alternatives }); + } + } + const chosen = (found, complete) => + found.find((entry) => entry.mono) ?? + found.find((entry) => entry.refs.length > 0) ?? + (complete ? found[0] : undefined) ?? + null; + // Every rule as the compounds of each selector it reads as, each with + // the ways its simple selectors read: an element of `.x:hover` is an + // `.x`, and one of `.p .x:hover` (or `.w .p .x:hover`) a `.p .x`, so a + // family they set reaches it, in the same context or where the base + // always applies. + const bases = compiledRules + .map((base) => ({ + ...base, + chains: base.alternatives.flatMap((alternative) => { + const chain = compoundsOf(alternative).map((part) => { + const simples = simplesOf(part.compound); + return ( + simples && { ...part, ways: alternativesOf(simples) } + ); + }); + return chain.every(Boolean) ? [chain] : []; + }), + })) + .filter(({ chains }) => chains.length); + // Whether a base reaches every element of a reader's `selector` read + // `way` at its target: its compounds, last to last, each contained in + // one of the reader's (`*` in any), in order and across what its + // combinators allow (a descendant across `>` or ` ` chains, `~` across + // `+` or `~` ones, `>` and `+` only their own); what the reader names + // besides only narrows it. + const covers = (base, selector, way) => { + const reader = compoundsOf(selector); + const within = (part, k) => { + const simples = ( + k === reader.length - 1 ? way : simplesOf(reader[k].compound) + )?.map(matchKey); + return part.ways.some((ways) => + ways.every((s) => s === '*' || simples?.includes(matchKey(s))) + ); + }; + // With `chain[i]` at the reader's `k`, whether the compounds before + // it fit further left, trying each place a span allows in turn (a + // nearer `.b` may leave no room for `.a > .b`'s `>`). + const fits = (chain, i, k) => { + if (i === 0) return true; + const combinator = chain[i].combinator; + const across = ACROSS[combinator] ?? [combinator]; + for (let j = k - 1; j >= 0; j -= 1) { + if (!across.includes(reader[j + 1].combinator)) return false; + if (within(chain[i - 1], j) && fits(chain, i - 1, j)) { + return true; + } + if (across.length === 1) return false; + } + return false; + }; + const last = reader.length - 1; + return base.chains.some( + (chain) => + within(chain.at(-1), last) && + fits(chain, chain.length - 1, last) + ); + }; + // Whether a rule reaches every element of `selector` (see `covers`), + // in any way its target reads. + const baseOf = new Map(bases.map((base) => [base.rule, base])); + const reaches = (form, selector) => { + const base = baseOf.get(form.rule); + const target = compoundsOf(selector).at(-1)?.compound; + const simples = base && target ? simplesOf(target) : null; + return Boolean( + simples && + alternativesOf(simples).some((way) => covers(base, selector, way)) + ); + }; + // A declaration's place in the cascade: `!important`, then its layer + // (see `layerPlace`; `!important` turns the order round), then + // specificity, then source order. + const rankOf = (entry, { selector, layer = [] }, order = 0) => ({ + important: levelOf(entry), + layer: layerPlace(layer), + specificity: specificityOf(selector), + order, + }); + // The cascade's winner among ranked candidates (`{ entry, rank }`). A + // `revert-layer` one rolls the cascade back past its layer: the winner + // among the others, else it inherits. + const winnerOf = (candidates) => { + const best = candidates.reduce( + (a, b) => (a && rankAbove(a.rank, b.rank) ? a : b), + null + ); + if (!best?.entry.revertLayer) return best; + const others = candidates.filter( + ({ rank }) => + rank.important !== best.rank.important || + ordered(rank.layer, best.rank.layer) !== 0 + ); + return winnerOf(others) ?? best; + }; + // The family the cascade gives an element of `selectors` (the rules + // on one element, as `html` and `:root` are), in `context` or always: + // `null` when none sets one or the winner inherits. + const winnerAt = (selectors, context) => { + const ranked = compiledRules + .filter( + (form) => + form.alternatives.some((alternative) => + selectors.includes(alternative) + ) || selectors.some((selector) => reaches(form, selector)) + ) + .filter((form) => encloses(form.context, context)) + .map((form) => ({ + entry: form.entry, + rank: rankOf(form.entry, form, orderOf.get(form.rule)), + })); + const best = winnerOf(ranked); + return best && !best.entry.inherit ? best.entry : null; + }; + // The family an at-rule block (`@media` inside a rule) sets itself. + const ownFamily = (block) => { + const rules = rulesOf(block.start); + const set = rules + .map((rule) => family.get(rule)) + .filter((entry) => entry && !entry.inherit); + const named = + set.find((entry) => entry.mono) ?? + set.find((entry) => entry.refs.length > 0); + if (named) return named; + return set.length && set.length === rules.length ? set[0] : null; + }; + // The family on an element of each of a rule's selectors: the cascade + // winner (`!important`, then specificity, then source order) among its + // own rule, the bases it contains and `*`. One whose winner inherits + // (or with none) takes the family its element inherits. + const elementFamily = (block) => { + // Each way a rule's target reads (`:is(.x, .y)` is `.x` or `.y`, + // `:where(.p .c)` is `.p .c`). + const targets = rulesOf(block.start).flatMap((rule) => { + const form = compiled(rule); + if (!form) return [{ rule, form }]; + return complexOf(form.selector).flatMap((selector) => { + const target = compoundsOf(selector).at(-1)?.compound; + const simples = simplesOf(target ?? '') ?? []; + return alternativesOf(simples).map((way) => ({ + ...{ rule, form, selector, way }, + })); + }); + }); + const ranked = (other) => ({ + entry: other.entry, + rank: rankOf(other.entry, other, orderOf.get(other.rule)), + }); + const winners = targets.map(({ rule, form, selector, way }) => { + if (!form) return family.get(rule) ?? null; + const applies = (other) => encloses(other.context, form.context); + const candidates = [ + ...bases + .filter(applies) + .filter((base) => covers(base, selector, way)) + .map(ranked), + // A rule on the same elements written another way + // (`:where(.p .c)` for `.p .c`). + ...compiledRules + .filter(applies) + .filter((other) => other.alternatives.includes(selector)) + .map(ranked), + ]; + const own = family.get(rule); + if (own && !candidates.some(({ entry }) => entry === own)) { + candidates.push({ + entry: own, + rank: rankOf(own, form, orderOf.get(rule)), + }); + } + const best = winnerOf(candidates); + return best?.entry ?? null; + }); + const set = winners.filter((entry) => entry && !entry.inherit); + const named = + set.find((entry) => entry.mono) ?? + set.find((entry) => entry.refs.length > 0); + if (named) return named; + return set.length && set.length === targets.length ? set[0] : null; + }; + // The nearest ancestor its compiled selector names (`.w .p .c` has + // `.w .p`, then `.w`) that sets a family, in its context or always: by + // that whole selector, or its last compound alone (`.p` is an element + // of `.w .p` too). + const fromAncestors = (block) => { + const found = rulesOf(block.start).flatMap((rule) => { + const form = compiled(rule); + const nearest = (selector) => { + for (const ancestor of ancestorsOf(selector)) { + const own = compoundsOf(ancestor).at(-1).compound; + const entry = winnerAt([ancestor, own], form.context); + if (entry) return [entry]; + } + return []; + }; + return form ? complexOf(form.selector).flatMap(nearest) : []; + }); + return chosen(found, true) ?? NONE; + }; + const lookup = (index) => { + const around = blocks + .filter((b) => b.start < index && index < b.end) + .filter((b) => b.kind !== 'flow') + .reverse(); + // A rule that puts its parent after a prefix (`.x &`) names more + // ancestors than its parents do: they are read from its compiled + // selector, once its parents (the same element) set no family, at + // the first parent without `&` (a top-level rule has none) or past + // the walk. + let deeper = null; + for (const block of around) { + if (/^@font-face\b/i.test(block.prelude)) return NONE; + // A selector list meets the cap when one of its selectors + // renders Mono; one without a family of its own inherits it. + const selector = styleSelector(block); + const own = + selector !== null ? elementFamily(block) : ownFamily(block); + if (own) return own; + // A rule on another element (not `&…`) inherits from the + // nearest ancestor its selector names. + if (selector?.includes('&')) { + if (!selector.startsWith('&')) deeper ??= block; + } else if (selector !== null) { + const ancestor = fromAncestors(deeper ?? block); + if (ancestor !== NONE) return ancestor; + deeper = null; + } + // An `@at-root` that leaves out a block around it (see + // `atRootExcludes`) is written out away from it: what is + // around it counts only as its compiled selector and context + // name it. One that leaves none out is the same element. + const excludes = atRootExcludes(block.prelude); + if (excludes) { + const leaves = around.some( + (b) => + b.start < block.start && excludes(blockKind(b.prelude)) + ); + if (leaves) break; + if (selector === null) continue; + } + if (!inherits(selector ?? block.prelude)) return NONE; + } + // Past such an `@at-root`, a `.x &` reads its ancestors here. + if (deeper) { + const ancestor = fromAncestors(deeper); + if (ancestor !== NONE) return ancestor; + } + // Else from the document's root: a plain `:host`, `body`, then the + // root element (`html` and `:root`), in the reader's context (its + // `@media`) or always. + const reader = around.find((b) => styleSelector(b) !== null); + const context = reader + ? (compiled(rulesOf(reader.start)[0] ?? '')?.context ?? + UNCONDITIONAL) + : UNCONDITIONAL; + for (const element of [[':host'], ['body'], ['html', ':root']]) { + const entry = winnerAt(element, context); + if (entry) return entry; + } + return NONE; + }; + // A weight in a mixin's body, or in a rule others extend, meets the + // 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()) => { + const place = placeOf(blocks, index); + const scope = fontNamespaceRule(blocks, place) ?? place.scope; + if (seen.has(scope)) return []; + seen.add(scope); + // 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); + const frames = scope === null ? null : framesOf(scope); + if (frames) { + if (!frames.live) return []; + return [ + ...(keep(scope) ? [lookup(index)] : []), + ...runsOf(frames).flatMap(({ index: site }) => + familyAt(site, keep, seen) + ), + ]; + } + const block = blocks.find((b) => b.start === scope); + const includes = sitesOf(scope); + const extended = extendersOf(scope).map((extender) => extender.index); + const silent = + includes.length > 0 || + (extended.length > 0 && /^%/.test(block?.prelude ?? '')); + return [ + ...(silent || !keep(scope) ? [] : [lookup(index)]), + ...[...includes, ...extended].flatMap((site) => + familyAt(site, keep, seen) + ), + ]; + }; + const monoAt = (index, keep = () => true) => { + const found = familyAt(index, keep).filter((entry) => entry !== NONE); + return ( + found.find((entry) => entry.mono) ?? + found.find((entry) => entry.refs.length > 0) ?? + found[0] ?? + NONE + ); + }; + // 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, + // so the rule after it needs reading too (`transient: false`). + monoAt.transient = [...runs].some( + ([name, list]) => + list.some((run) => !run.hold) && + FONT_IN_FRAMES.test(framesText(definedLast.get(name))) + ); + monoAt.landings = (index) => { + const place = placeOf(blocks, index); + const scope = fontNamespaceRule(blocks, place) ?? place.scope; + return scope === null ? [] : landingsOf(scope, index); + }; + return monoAt; +} diff --git a/tools/nx/font-weight-lexer.mjs b/tools/nx/font-weight-lexer.mjs new file mode 100644 index 000000000..ec135e81e --- /dev/null +++ b/tools/nx/font-weight-lexer.mjs @@ -0,0 +1,859 @@ +/** + * Just enough lexing for `check-font-weights.mjs`: comments, the string that + * encloses each position, and where a declaration's value ends. + */ + +const QUOTES = new Set(["'", '"', '`']); +const VALUE_END = new Set([';', '{', '}', ']']); + +/** + * Plain CSS has block comments only; SCSS and TypeScript add `//`. In SCSS + * a `//` is a comment anywhere outside a string (`font-weight:// old`, `(// + * note`) except in an unquoted `url(//cdn…)` or `url(https://…)`. In + * TypeScript a URL is always inside a string, so every `//` there is a + * comment. + */ +function syntaxOf(file) { + // SVG is markup like HTML: attributes, `` comments, `