fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780)

Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render
real semibold and bold faces instead of a synthetic bold, keep
<html lang> in step with the UI language, and move every font weight onto
the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard
LIVE badge now uses the interface font at 700).

Add the `styles:font-weights:validate` ratchet guard and its CI step. It
reads stylesheets much as Sass and the browser do (cascade, layers,
mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`,
keyframes) and lists what it deliberately does not trace in its header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-04 08:55:23 +02:00
1 parent ab8460338a
commit e8b181fcea
52 files changed
+11216 -97

No files matched your search

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