Merge remote-tracking branch 'origin/master' into claude/great-herschel-e690e4

# Conflicts:
#	.github/workflows/ci.yml
#	apps/web/src/app/app.config.ts
#	apps/web/src/m3-theme.scss
#	libs/playlist/import/feature/src/lib/text-import/text-import.component.ts
#	package.json
#	tools/nx/check-form-field-defaults.mjs
#	tools/nx/check-form-field-defaults.test.mjs
#	tools/performance/journey-baselines.json
#	tsconfig.base.json
This commit is contained in:
4gray committed 2026-10-13 22:01:10 +02:00
commit a39ca37e07
42 files changed
+644 -551

No files matched your search

@@ -0,0 +1,7 @@
---
type: perf
area: performance
---
The app loads less code at startup: the text field component and its form
helpers now load with the screens that show a form instead of with the app.
@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies.
+4 -4
View File
@@ -3,7 +3,7 @@ type: perf
area: web
---
The app loads about 15% less code before it shows its first screen: form
field, dialog, tooltip and progress-bar code, the animation engine and the
theme colours of unused components now load only when something needs them,
so every launch has less to download and parse.
The app loads about 12% less code before it shows its first screen: dialog,
tooltip and progress-bar code, the animation engine and the theme colours of
unused components now load only when something needs them, so every launch
has less to download and parse.
+8 -4
View File
@@ -1,10 +1,11 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched weekly. Nx minor+patch updates use a dedicated group so
# official packages move together; Nx majors are manual coordinated migrations.
# Native packaging, parser, and version-locked playback dependencies stay in
# standalone PRs; other minor+patch updates are grouped per ecosystem. Security
# updates are separate and are not limited by this schedule; CI enforces complete
# Nx lockstep.
# Electron majors are held while Linux armv7l packages ship: Electron 44
# dropped the linux-armv7l binary. Native packaging, parser, and
# version-locked playback dependencies stay in standalone PRs; other
# minor+patch updates are grouped per ecosystem. Security updates are separate
# and are not limited by this schedule; CI enforces complete Nx lockstep.
version: 2
updates:
- package-ecosystem: npm
@@ -21,6 +22,9 @@ updates:
- dependency-name: '@nx/*'
update-types:
- version-update:semver-major
- dependency-name: electron
update-types:
- version-update:semver-major
groups:
nx-version-updates:
applies-to: version-updates
+4 -4
View File
@@ -430,11 +430,11 @@ jobs:
- name: Validate icon-only button names
run: pnpm run a11y:icon-buttons:validate
# The form-field defaults travel with AppFormFieldModule, not a
# root provider: a component that renders <mat-form-field>
# without it silently gets Material's filled look.
# Form-field defaults are provided per lazy component: the root
# token import would load Material form-field and @angular/forms
# on startup (tools/nx/check-form-field-defaults.mjs).
- name: Validate form-field defaults
run: pnpm run styles:form-field-defaults:validate
run: pnpm run ui:form-field-defaults:validate
# m3-theme.scss themes only the Material components the app
# imports; a new import without a theme entry renders unthemed.
@@ -8,10 +8,10 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import {
AppUpdateChannel,
APP_UPDATE_CHANNELS,
@@ -20,6 +20,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface SettingsUpdateStatusChip {
readonly labelKey: string;
@@ -28,9 +29,10 @@ interface SettingsUpdateStatusChip {
@Component({
selector: 'app-settings-about-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
@@ -12,24 +12,26 @@ import {
ReactiveFormsModule,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { EpgViewMode } from '@iptvnator/shared/interfaces';
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
import { EpgViewModeOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-epg-section',
providers: [provideFormFieldDefaults()],
imports: [
EpgSourceStatusComponent,
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
@@ -7,11 +7,11 @@ import {
ChangeDetectionStrategy,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
import {
CoverSizeOption,
@@ -20,12 +20,14 @@ import {
ThemeOption,
} from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-general-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
@@ -1,5 +1,6 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import {
@@ -7,14 +8,15 @@ import {
MatSlideToggleModule,
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-parental-lock-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
@@ -8,13 +8,13 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import {
StreamFormat,
VideoPlayer,
@@ -22,13 +22,15 @@ import {
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-playback-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
@@ -7,20 +7,22 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-remote-control-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSlideToggleModule,
@@ -8,23 +8,25 @@ import {
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({
selector: 'app-settings-tmdb-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
+3 -2
View File
@@ -329,8 +329,9 @@ html {
// ─── Global refined outlined inputs ──────────────────────────────────────────
// Applied app-wide AFTER the component themes so these overrides win the
// cascade. Pairs with the outline + dynamic defaults that AppFormFieldModule
// (libs/ui/components, form-field) gives every form field.
// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
// component rendering a form field.
$app-refined-input-density: mat.define-theme((
density: (scale: -2),
));
+14 -8
View File
@@ -97,14 +97,13 @@ therefore reaches the initial bundle only through the snack-bar, which root
services inject (and with it `button`, the icon button and the CDK overlay).
Everything else is lazy by these rules:
- **Form-field defaults.** Import `AppFormFieldModule`
(`@iptvnator/ui/components/form-field`) instead of `MatFormFieldModule`
wherever a template renders `<mat-form-field>`: it exports Material's form
field with a directive that provides the app defaults (outlined, dynamic
subscript) on the element. The defaults are not a root provider, and a
route-level one would miss dialogs opened by root services.
`pnpm run styles:form-field-defaults:validate` (CI) rejects a component that
renders a form field without the module.
- **Form-field defaults.** A component that renders `<mat-form-field>`
adds `providers: [provideFormFieldDefaults()]`
(`@iptvnator/ui/components/form-field-defaults`); the token is never
provided at the root, where its import alone put the 54 KB form-field
chunk on the initial path. `pnpm run ui:form-field-defaults:validate` (CI)
requires the provider on every such component and allows the token
nowhere else; see the Dialogs and forms notes below.
- **Dialogs from services created with the app.** A root service that opens a
dialog resolves `MatDialog` through `LazyMatDialog`
(`@iptvnator/ui/components/lazy-dialog`) and imports the dialog component
@@ -1083,6 +1082,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
## Forms
Every `<mat-form-field>` is outlined with a dynamic subscript. A component
that renders one adds `providers: [provideFormFieldDefaults()]`
(`@iptvnator/ui/components/form-field-defaults`); the root injector does not
provide the defaults, because the token import there would load Material's
form field and `@angular/forms` on startup. `pnpm run
ui:form-field-defaults:validate` (in CI) reports a missing provider.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
+1 -1
View File
@@ -1163,7 +1163,7 @@ form-field chunk on the initial path, although every form field renders in a
lazy route or dialog. Angular Material therefore stays off the startup path by
construction, with the rules and guards in the
[UI guidelines](iptvnator-ui-guidelines.md#material-off-the-startup-path):
form-field defaults travel with `AppFormFieldModule`, services created with
form-field defaults are provided per component (#1928), services created with
the app resolve `MatDialog` through `LazyMatDialog`, the panels the shell
mounts on every launch defer their Material views, the theme emits tokens only
for imported components, and the app does not provide the animations module,
+6
View File
@@ -432,6 +432,12 @@ Detail metadata rule:
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
- It also runs AFTER enrichment: the series view re-normalizes the store item
on every render, so it carries every `tmdb_*` field over. The key list
(`TMDB_ENRICHMENT_KEYS`) is typed as a `Record` over `StalkerVodInfo`'s
`tmdb_*` keys, so a new enrichment field fails to compile until it is listed
there instead of silently vanishing from the view (as the genre and country
Discover chips once did).
## Stalker Identity Policy
@@ -832,8 +832,10 @@ Contracts worth keeping:
credits and no recommendations carries none of them and reads as
un-enriched. Since the column is never overwritten, getting this wrong is
unfixable after the fact — a real provider date arriving later cannot
correct it. The same marker is what `trustedReleaseYear` (the
recommendations exclusion index) must consult if it ever reads this column.
correct it. Stalker entries never reach this column, but the same marker
rides in their stored `info`, and `trustedReleaseYear` (the recommendations
exclusion index) reads it first: a date TMDB supplied does not gate the
exact tier. `createStalkerInfo()` keeps the marker through re-normalization.
- **The id is stored unvetted.** Every consumer reaches TMDB through
`TmdbEnrichmentService`, whose `detailsForProviderId` runs
`assessProviderId` and lets the title search take over when the years
+3
View File
@@ -878,6 +878,9 @@ Toolchain notes for the Electron 43 baseline:
Those dependencies require standalone PRs so their dedicated package,
worker, playback, and diagnostic-contract validation cannot be hidden by an
unrelated grouped update.
Electron majors are ignored: Electron 44 ships no `linux-armv7l` binary,
and the release still packages armv7l AppImage, DEB and Snap builds. Remove
the ignore together with those targets when 32-bit ARM support is dropped.
4. Electron 42 and newer download their development binary on the first
Electron command instead of during package `postinstall`, so `electron`
must not remain in pnpm's `onlyBuiltDependencies` allowlist. The first local
@@ -9,10 +9,10 @@ import {
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import {
detectProviderImportCandidates,
ProviderImportCandidate,
@@ -20,6 +20,7 @@ import {
ProviderImportKind,
SOURCE_TYPE_ICONS,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface CandidateSummaryRow {
labelKey: string;
@@ -145,12 +146,13 @@ function maskUrlQueryPasswords(url: string): string {
*/
@Component({
selector: 'app-auto-import',
providers: [provideFormFieldDefaults()],
templateUrl: './auto-import.component.html',
styleUrl: './auto-import.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
AppFormFieldModule,
MatFormFieldModule,
MatIcon,
MatInputModule,
ReactiveFormsModule,
@@ -18,12 +18,12 @@ import {
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
legacyTransformStalkerPortalUrl,
@@ -50,6 +50,7 @@ import {
toStalkerImportFeedback,
toStalkerPlaylistIdentityFields,
} from './stalker-import-identity';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
/**
* A MAC and the device IDs that belong to exactly it. Kept together because
@@ -66,7 +67,7 @@ interface StalkerSettledIdentity {
imports: [
FormsModule,
MatCheckboxModule,
AppFormFieldModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
@@ -75,6 +76,7 @@ interface StalkerSettledIdentity {
TranslatePipe,
],
selector: 'app-stalker-portal-import',
providers: [provideFormFieldDefaults()],
templateUrl: './stalker-portal-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -3,7 +3,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { MockModule } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';
@@ -15,9 +14,6 @@ describe('TextImportComponent', () => {
TestBed.configureTestingModule({
imports: [
TextImportComponent,
// The form field and its input are mocked together: a real
// MatFormField would look for the mocked input's state.
MockModule(AppFormFieldModule),
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
@@ -12,19 +12,15 @@ import {
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-text-import',
providers: [provideFormFieldDefaults()],
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AppFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();
@@ -11,17 +11,19 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-url-upload',
providers: [provideFormFieldDefaults()],
templateUrl: './url-upload.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AppFormFieldModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
@@ -228,6 +228,26 @@ describe('XtreamCodeImportComponent form', () => {
);
}
it('outlines its fields with a dynamic subscript without a root provider', () => {
// The root injector no longer provides MAT_FORM_FIELD_DEFAULT_OPTIONS
// (it pulled Material's form field and @angular/forms into startup);
// the form carries provideFormFieldDefaults() itself.
const { root } = render();
const fields = Array.from(root.querySelectorAll('mat-form-field'));
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field.classList).toContain(
'mat-form-field-appearance-outline'
);
expect(
field.querySelector(
'.mat-mdc-form-field-subscript-dynamic-size'
)
).not.toBeNull();
}
});
it('labels the name field like the other add-source forms', () => {
const { root } = render();
@@ -16,11 +16,11 @@ import {
Validators,
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { createXtreamConnectionTestState } from '@iptvnator/services';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
@@ -30,6 +30,7 @@ import {
normalizeXtreamServerUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
function xtreamServerUrlValidator(
control: AbstractControl
@@ -50,7 +51,7 @@ function xtreamServerUrlValidator(
@Component({
imports: [
FormsModule,
AppFormFieldModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
@@ -59,6 +60,7 @@ function xtreamServerUrlValidator(
TranslatePipe,
],
selector: 'app-xtream-code-import',
providers: [provideFormFieldDefaults()],
templateUrl: './xtream-code-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -28,7 +28,6 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { firstValueFrom } from 'rxjs';
@@ -63,6 +62,7 @@ import {
STALKER_PLAYLIST_CONNECTION_EDITOR,
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
} from './stalker-playlist-connection-editor.token';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
@@ -166,10 +166,9 @@ type DesktopFileSaveBridge = Pick<
}
`,
],
providers: [DatePipe],
providers: [provideFormFieldDefaults(), DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AppFormFieldModule,
ClipboardModule,
MatButton,
MatCheckboxModule,
@@ -253,44 +253,71 @@ describe('stalker-vod.utils regressions', () => {
});
it('preserves TMDB enrichment fields through info re-normalization', () => {
const tmdbCast = [{ name: 'Karl Urban', profileUrl: null }];
// Detail views re-normalize the selected item on every render, so
// every field `mergeStalkerInfoWithTmdb` can set has to survive here
const tmdbCast = [{ name: 'Rowan Quill', profileUrl: null }];
const tmdbRecommendations = [
{ tmdbId: 1, title: 'Invincible', year: 2021, posterUrl: null },
{ tmdbId: 1, title: 'Harbor Static', year: 2021, posterUrl: null },
];
const tmdbGenres = [{ id: 18, name: 'Drama' }];
const tmdbCountries = [{ code: 'NO', name: 'Norway' }];
const info = createStalkerInfo({
id: '7',
info: {
name: 'The Boys s05',
name: 'Lantern Coast s05',
movie_image: 'http://portal/poster.jpg',
description: 'Plot',
actors: 'Karl Urban',
actors: 'Rowan Quill',
director: '',
releasedate: '2026',
genre: 'Action',
releasedate: '2026-03-01',
genre: 'Drama',
rating_imdb: '',
rating_kinopoisk: '8.1',
tmdb_id: 4242,
tmdb_cast: tmdbCast,
tmdb_directors: [
{
name: 'Eric Kripke',
name: 'Mara Tollen',
profileUrl: null,
tmdbPersonId: 1216630,
},
],
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg',
tmdb_status: 'returning',
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/coast.jpg',
tmdb_trailer: 'abc123def',
tmdb_recommendations: tmdbRecommendations,
tmdb_genres: tmdbGenres,
tmdb_countries: tmdbCountries,
tmdb_media_type: 'tv',
tmdb_supplied_release_date: true,
},
});
expect(info.tmdb_id).toBe(4242);
expect(info.tmdb_cast).toEqual(tmdbCast);
expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke');
expect(info.tmdb_directors?.[0]?.name).toBe('Mara Tollen');
expect(info.tmdb_status).toBe('returning');
expect(info.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/boys.jpg'
'https://image.tmdb.org/t/p/w1280/coast.jpg'
);
expect(info.tmdb_trailer).toBe('abc123def');
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
expect(info.tmdb_genres).toEqual(tmdbGenres);
expect(info.tmdb_countries).toEqual(tmdbCountries);
expect(info.tmdb_media_type).toBe('tv');
expect(info.tmdb_supplied_release_date).toBe(true);
});
it('adds no TMDB fields to a row that was never enriched', () => {
const info = createStalkerInfo({
id: '8',
info: { name: 'Unenriched Title', releasedate: '2019' },
});
expect(
Object.keys(info).filter((key) => key.startsWith('tmdb_'))
).toEqual([]);
});
describe('temporary-link flags survive normalization', () => {
@@ -188,20 +188,46 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
genre: text(info.genre, item.genre, item.genres_str),
rating_imdb: rating(info.rating_imdb, item.rating_imdb),
rating_kinopoisk: rating(info.rating_kinopoisk, item.rating_kinopoisk),
// TMDB enrichment fields must survive re-normalization — detail
// views normalize the selected item on every render
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}),
...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}),
...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}),
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
...(info.tmdb_recommendations
? { tmdb_recommendations: info.tmdb_recommendations }
: {}),
...pickTmdbEnrichment(info),
};
}
type TmdbEnrichmentKey = Extract<keyof StalkerVodInfo, `tmdb_${string}`>;
/**
* Every TMDB enrichment field of {@link StalkerVodInfo}. A `Record` over the
* `tmdb_*` keys, so adding one to the interface fails to compile until it is
* listed here: detail views re-normalize the selected item on every render,
* and a field this list missed would vanish right after enrichment.
*/
const TMDB_ENRICHMENT_KEYS: Record<TmdbEnrichmentKey, true> = {
tmdb_id: true,
tmdb_cast: true,
tmdb_directors: true,
tmdb_status: true,
tmdb_backdrop: true,
tmdb_trailer: true,
tmdb_recommendations: true,
tmdb_genres: true,
tmdb_countries: true,
tmdb_media_type: true,
tmdb_supplied_release_date: true,
};
function pickTmdbEnrichment(
info: Partial<StalkerVodInfo>
): Partial<Pick<StalkerVodInfo, TmdbEnrichmentKey>> {
const picked: Record<string, unknown> = {};
for (const key of Object.keys(
TMDB_ENRICHMENT_KEYS
) as TmdbEnrichmentKey[]) {
if (info[key]) {
picked[key] = info[key];
}
}
return picked;
}
/**
* Narrows a raw portal row to the fields the detail/playback flows need.
*
@@ -0,0 +1,64 @@
import { ComponentFixture } from '@angular/core/testing';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
/**
* The view re-normalizes the selected item on every render, so the TMDB
* genre and country facets the enrichment merged into the store item have
* to survive that pass to become clickable Discover chips.
*/
describe('StalkerSeriesViewComponent TMDB facet chips', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() =>
jest.fn()
);
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
function facetChipLabels(): string[] {
return Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'button.meta-chip__facet'
)
).map((chip) => chip.textContent?.trim() ?? '');
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('renders the enriched genres and countries as Discover chips', async () => {
await stabilize();
// Enrichment patches the store item after selection
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
genre: 'Drama',
tmdb_id: 777,
tmdb_genres: [
{ id: 18, name: 'Drama' },
{ id: 9648, name: 'Mystery' },
],
tmdb_countries: [{ code: 'NO', name: 'Norway' }],
tmdb_media_type: 'tv',
},
} as never);
await stabilize();
expect(facetChipLabels()).toEqual(
expect.arrayContaining(['Drama', 'Mystery', 'Norway'])
);
});
});
@@ -64,4 +64,6 @@ export interface StalkerVodInfo {
* this to send Discover clicks to the right media type.
*/
tmdb_media_type?: TmdbMediaType;
/** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */
tmdb_supplied_release_date?: boolean;
}
+1 -1
View File
@@ -9,8 +9,8 @@ export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
export * from './lib/expandable-text/expandable-text.component';
export * from './lib/form-field-defaults/form-field-defaults';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/form-field/app-form-field.module';
export * from './lib/lazy-dialog/lazy-mat-dialog';
export * from './lib/portal-detail-shell/content-about.component';
export * from './lib/portal-detail-shell/detail-template.directives';
@@ -14,14 +14,15 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AppFormFieldModule } from '../../form-field/app-form-field.module';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
import { provideFormFieldDefaults } from '../../form-field-defaults/form-field-defaults';
export interface EpgMappingDialogData {
channelKey: string;
@@ -39,6 +40,7 @@ const SEARCH_MIN_CHARS = 2;
@Component({
selector: 'app-epg-mapping-dialog',
providers: [provideFormFieldDefaults()],
templateUrl: './epg-mapping-dialog.component.html',
styleUrl: './epg-mapping-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -46,7 +48,7 @@ const SEARCH_MIN_CHARS = 2;
FormsModule,
MatButtonModule,
MatDialogModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
@@ -0,0 +1,25 @@
import { Provider } from '@angular/core';
import {
MAT_FORM_FIELD_DEFAULT_OPTIONS,
MatFormFieldDefaultOptions,
} from '@angular/material/form-field';
/** The app's outlined, dynamically sized form fields (see m3-theme.scss). */
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
appearance: 'outline',
subscriptSizing: 'dynamic',
};
/**
* Provide the app's form-field defaults on a component that renders
* `<mat-form-field>`. They are not provided in the root injector: importing
* the token there loads `@angular/material/form-field` (and, since Material
* 22.2, `@angular/forms`) on startup, while every form field lives in a lazy
* chunk. `tools/nx/check-form-field-defaults.mjs` enforces this.
*/
export function provideFormFieldDefaults(): Provider {
return {
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: APP_FORM_FIELD_DEFAULTS,
};
}
@@ -1,54 +0,0 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { AppFormFieldModule } from './app-form-field.module';
@Component({
imports: [AppFormFieldModule, MatInputModule],
template: `
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput />
<mat-hint>Short</mat-hint>
</mat-form-field>
`,
})
class WithDefaultsComponent {}
@Component({
imports: [MatFormFieldModule, MatInputModule],
template: `
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput />
</mat-form-field>
`,
})
class WithoutDefaultsComponent {}
function formField(component: typeof WithDefaultsComponent): HTMLElement {
const fixture = TestBed.createComponent(component);
fixture.detectChanges();
return fixture.nativeElement.querySelector('mat-form-field');
}
describe('AppFormFieldModule', () => {
it('gives a form field the outlined appearance and dynamic subscript', () => {
const field = formField(WithDefaultsComponent);
expect(field.classList).toContain('mat-form-field-appearance-outline');
expect(field.querySelector('.mdc-notched-outline')).not.toBeNull();
// Dynamic sizing reserves no empty subscript row below the field.
expect(
field.querySelector('.mat-mdc-form-field-subscript-dynamic-size')
).not.toBeNull();
});
it('is the only source of those defaults: Material alone renders filled', () => {
const field = formField(WithoutDefaultsComponent);
expect(field.classList).toContain('mat-form-field-appearance-fill');
expect(field.querySelector('.mdc-notched-outline')).toBeNull();
});
});
@@ -1,49 +0,0 @@
import { Directive, NgModule } from '@angular/core';
import {
MAT_FORM_FIELD_DEFAULT_OPTIONS,
MatFormFieldDefaultOptions,
MatFormFieldModule,
} from '@angular/material/form-field';
/** The app-wide form field: outlined, with hints and errors sized as needed. */
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
appearance: 'outline',
subscriptSizing: 'dynamic',
};
/**
* Gives every `<mat-form-field>` the app defaults through its own element
* injector, which `MatFormField` reads before any environment injector.
*
* Provided at the root, the defaults were a static import of
* `@angular/material/form-field` in `app.config.ts`, and one eager import of
* any export puts a module's whole bundler chunk on the initial path: 54 KB
* of form-field code for an app whose every form field renders in a lazy
* route or dialog. A route-level provider would miss the dialogs that root
* services open, so the defaults travel with the element instead.
*/
@Directive({
// The directive must match the Material element itself: an attribute
// would have to be added to every form field by hand.
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'mat-form-field',
providers: [
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: APP_FORM_FIELD_DEFAULTS,
},
],
})
export class AppFormFieldDefaultsDirective {}
/**
* Import this, not `MatFormFieldModule`, wherever a template renders
* `<mat-form-field>`; it exports Material's form field together with the
* defaults directive. `pnpm run styles:form-field-defaults:validate` (CI)
* rejects a component that renders a form field without it.
*/
@NgModule({
imports: [MatFormFieldModule, AppFormFieldDefaultsDirective],
exports: [MatFormFieldModule, AppFormFieldDefaultsDirective],
})
export class AppFormFieldModule {}
@@ -16,10 +16,10 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '../form-field/app-form-field.module';
import {
createParentalLockPinThrottle,
isValidParentalLockPin,
@@ -27,6 +27,7 @@ import {
PARENTAL_LOCK_PIN_MAX_LENGTH,
PARENTAL_LOCK_PIN_MIN_LENGTH,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '../form-field-defaults/form-field-defaults';
export type ParentalLockPinDialogMode = 'unlock' | 'set';
@@ -59,6 +60,7 @@ export interface ParentalLockPinDialogData {
*/
@Component({
selector: 'app-parental-lock-pin-dialog',
providers: [provideFormFieldDefaults()],
templateUrl: './parental-lock-pin-dialog.component.html',
styleUrl: './parental-lock-pin-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -66,7 +68,7 @@ export interface ParentalLockPinDialogData {
FormsModule,
MatButtonModule,
MatDialogModule,
AppFormFieldModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslatePipe,
@@ -885,6 +885,45 @@ describe('DashboardRecommendationsService', () => {
expect(service.items().map((item) => item.title)).toContain('Godzilla');
});
it('ignores a release date the TMDB merge supplied when excluding', async () => {
// The portal sent no date, so the stored one is the year of
// whatever TMDB matched. It must not gate the exact tier, or a
// namesake match would keep the watched title on the rail.
enrichMovie.mockResolvedValue({
recommendations: {
results: [
...recTitles
.slice(0, 5)
.map((title, i) => rec(100 + i, title)),
rec(200, 'Quarry Lights', 2014),
],
},
});
recentAll = [
...recentVod,
{
title: 'Quarry Lights',
type: 'movie',
stalker_item: {
id: '9',
category_id: 'vod',
info: {
name: 'Quarry Lights',
releasedate: '1954-11-03',
tmdb_supplied_release_date: true,
},
},
} as ActivityStub,
];
const service = createService();
await service.load();
expect(service.items().map((item) => item.title)).not.toContain(
'Quarry Lights'
);
});
it('excludes a watched title whose name ends in a year', async () => {
// "Blade Runner 2049" is a 2017 film; the 2049 belongs to the
// NAME, so it must not be read as a release year and used to
@@ -52,7 +52,10 @@ export type RecommendationCandidate = Omit<
* gating that key on a title-derived 2049 would fail to exclude the very
* film the user just watched (TMDB calls it 2017). Only Stalker rows
* carry a real date (`info.releasedate`); everything else yields `null`,
* which keeps the exact tier excluding unconditionally.
* which keeps the exact tier excluding unconditionally. So does a date the
* TMDB merge filled in itself (`tmdb_supplied_release_date`): it is the
* year of whatever TMDB matched, and a namesake match would otherwise stop
* the watched title from excluding itself.
*
* The base tier is different by construction — its key exists only
* because a trailing year was stripped off — so it keeps using that year.
@@ -63,6 +66,9 @@ export function trustedReleaseYear(
const info = (
item.stalker_item as { info?: Record<string, unknown> } | undefined
)?.info;
if (info?.['tmdb_supplied_release_date'] === true) {
return null;
}
const releaseDate = info?.['releasedate'];
return typeof releaseDate === 'string' ? extractYear(releaseDate) : null;
}
+3 -3
View File
@@ -62,9 +62,9 @@
"a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs",
"a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs",
"a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check",
"styles:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
"styles:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
"styles:form-field-defaults:validate": "pnpm run styles:form-field-defaults:test && pnpm run styles:form-field-defaults:check",
"ui:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
"ui:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
"ui:form-field-defaults:validate": "pnpm run ui:form-field-defaults:test && pnpm run ui:form-field-defaults:check",
"styles:material-theme-scope:test": "node --test tools/nx/check-material-theme-scope.test.mjs",
"styles:material-theme-scope:check": "node tools/nx/check-material-theme-scope.mjs",
"styles:material-theme-scope:validate": "pnpm run styles:material-theme-scope:test && pnpm run styles:material-theme-scope:check",
+111 -158
View File
@@ -2,188 +2,148 @@ import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseTemplate,
TmplAstRecursiveVisitor,
tmplAstVisitAll,
} from '@angular/compiler';
import ts from 'typescript';
import { isScanned } from './check-theme-references.mjs';
/**
* The app-wide form-field defaults (outlined, dynamic subscript) are not a
* root provider: that import put the whole form-field chunk on the initial
* path. `AppFormFieldModule` carries them on the element instead, so a
* component that renders `<mat-form-field>` without importing it silently
* gets Material's filled look. This guard requires the import.
* The app's `<mat-form-field>` defaults (outline, dynamic subscript) are
* provided per component, not in the root injector: importing
* MAT_FORM_FIELD_DEFAULT_OPTIONS in app.config.ts loads
* `@angular/material/form-field` and, since Material 22.2, `@angular/forms`
* on startup, although every form field lives in a lazy chunk. A component
* that renders a form field without the provider silently falls back to the
* `fill` appearance, so this guard requires it on each such component, and
* allows the provider and the token nowhere else.
*/
export const DEFAULTS_FILE =
'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts';
const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS';
const PROVIDER = 'provideFormFieldDefaults';
/** Angular renderer sources whose components can render a form field. */
const SCANNED_PATHSPECS = [
'apps/web/*.ts',
'apps/remote-control-web/*.ts',
'libs/*.ts',
];
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
export const DEFAULTS_MODULE = 'AppFormFieldModule';
export const DEFAULTS_ALIAS = '@iptvnator/ui/components/form-field';
const DEFAULTS_SOURCE = /(^|\/)app-form-field\.module$/;
function isScanned(file) {
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
}
const FORM_FIELD_ELEMENT = 'mat-form-field';
/** Identifiers listed in an `imports: [...]` array, nested arrays included. */
function importIdentifiers(expression) {
if (ts.isArrayLiteralExpression(expression)) {
return expression.elements.flatMap(importIdentifiers);
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
function componentMetadata(node) {
if (
!ts.isDecorator(node) ||
!ts.isCallExpression(node.expression) ||
!ts.isIdentifier(node.expression.expression) ||
node.expression.expression.text !== 'Component'
) {
return undefined;
}
return ts.isIdentifier(expression) ? [expression.text] : [];
}
function propertyNamed(object, name) {
return object.properties.find(
(property) =>
ts.isPropertyAssignment(property) &&
(ts.isIdentifier(property.name) ||
ts.isStringLiteral(property.name)) &&
property.name.text === name
);
}
function stringValue(property) {
const value = property?.initializer;
return value &&
(ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
? value.text
const [metadata] = node.expression.arguments;
return metadata && ts.isObjectLiteralExpression(metadata)
? metadata
: undefined;
}
function property(metadata, name) {
return metadata.properties.find(
(entry) =>
ts.isPropertyAssignment(entry) &&
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
entry.name.text === name
)?.initializer;
}
/** Literal text of a quoted, backticked or interpolated template. */
function literalText(node) {
if (ts.isStringLiteralLike(node)) return node.text;
if (ts.isTemplateExpression(node)) {
return [
node.head.text,
...node.templateSpans.map((span) => span.literal.text),
].join('');
}
return undefined;
}
function isProviderCall(node) {
return (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === PROVIDER
);
}
/**
* The `@Component({...})` declarations of a TypeScript source: class name,
* the line its decorator starts on, inline `template` or `templateUrl`, and
* the identifiers of its `imports` array. Whether the source binds `AppFormFieldModule` from its
* module is reported alongside, since a same-named local class would not
* carry the defaults.
* Findings for one TypeScript source. `readTemplate` resolves a
* `templateUrl` relative to the component and returns its text.
*/
export function componentDeclarations(source, file) {
export async function checkComponentSource(file, source, readTemplate) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true
);
const lineOf = (node) =>
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
.line + 1;
const findings = [];
const components = [];
let bindsDefaultsModule = false;
const componentProviderCalls = new Set();
const visit = (node) => {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
(node.moduleSpecifier.text === DEFAULTS_ALIAS ||
DEFAULTS_SOURCE.test(node.moduleSpecifier.text)) &&
node.importClause?.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings) &&
node.importClause.namedBindings.elements.some(
(element) => element.name.text === DEFAULTS_MODULE
)
) {
bindsDefaultsModule = true;
const metadata = componentMetadata(node);
if (metadata) {
const providers = property(metadata, 'providers');
const calls =
providers && ts.isArrayLiteralExpression(providers)
? providers.elements.filter(isProviderCall)
: [];
calls.forEach((call) => componentProviderCalls.add(call));
components.push({ node, metadata, provides: calls.length > 0 });
}
if (
ts.isDecorator(node) &&
ts.isCallExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'Component' &&
ts.isClassDeclaration(node.parent)
) {
const [metadata] = node.expression.arguments;
if (metadata && ts.isObjectLiteralExpression(metadata)) {
const importsProperty = propertyNamed(metadata, 'imports');
components.push({
className: node.parent.name?.text ?? '(anonymous)',
line:
sourceFile.getLineAndCharacterOfPosition(
node.parent.getStart(sourceFile)
).line + 1,
template: stringValue(propertyNamed(metadata, 'template')),
templateUrl: stringValue(
propertyNamed(metadata, 'templateUrl')
),
imports: importsProperty
? importIdentifiers(importsProperty.initializer)
: [],
if (ts.isIdentifier(node) && node.text === TOKEN) {
if (file !== DEFAULTS_FILE) {
findings.push({
file,
line: lineOf(node),
reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return { components, bindsDefaultsModule };
}
class FormFieldVisitor extends TmplAstRecursiveVisitor {
found = false;
visitElement(element) {
if (element.name === FORM_FIELD_ELEMENT) this.found = true;
super.visitElement(element);
}
}
/** Whether a template renders `<mat-form-field>` anywhere, control flow included. */
export function templateRendersFormField(template, file) {
const parsed = parseTemplate(template, file, {
preserveWhitespaces: true,
});
if (parsed.errors?.length) {
throw new Error(
`${file}: cannot parse template: ${parsed.errors[0].msg}`
);
}
const visitor = new FormFieldVisitor();
tmplAstVisitAll(visitor, parsed.nodes);
return visitor.found;
}
/**
* Components of one source that render a form field without importing the
* defaults module. `readTemplate` resolves a `templateUrl` to its text.
*/
export async function findFormFieldsWithoutDefaults(
file,
source,
readTemplate
) {
const { components, bindsDefaultsModule } = componentDeclarations(
source,
file
);
const findings = [];
for (const component of components) {
const template =
component.template ??
(component.templateUrl
? await readTemplate(component.templateUrl)
: undefined);
if (template === undefined) continue;
if (!templateRendersFormField(template, file)) continue;
if (
bindsDefaultsModule &&
component.imports.includes(DEFAULTS_MODULE)
) {
continue;
const visitCalls = (node) => {
if (isProviderCall(node) && !componentProviderCalls.has(node)) {
findings.push({
file,
line: lineOf(node),
reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`,
});
}
ts.forEachChild(node, visitCalls);
};
visitCalls(sourceFile);
for (const { node, metadata, provides } of components) {
const inline = property(metadata, 'template');
const url = property(metadata, 'templateUrl');
let template = inline ? literalText(inline) : undefined;
if (template === undefined && url && ts.isStringLiteralLike(url)) {
template = await readTemplate(
path.posix.join(path.posix.dirname(file), url.text)
);
}
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
findings.push({
file,
line: component.line,
className: component.className,
line: lineOf(node),
reason: `renders <mat-form-field> without providers: [${PROVIDER}()]`,
});
}
return findings;
return findings.sort((a, b) => a.line - b.line);
}
/** Tracked renderer sources the guard reads, at any depth. */
/** Tracked renderer TypeScript files the guard reads, at any depth. */
export function listScannedFiles(rootDir) {
// No shell: `cmd.exe` treats single quotes as literal characters, so a
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
@@ -206,34 +166,27 @@ const isMain =
if (isMain) {
const rootDir = process.cwd();
const files = listScannedFiles(rootDir);
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
const findings = [];
for (const file of files) {
const source = await readFile(path.join(rootDir, file), 'utf8');
if (
!source.includes(FORM_FIELD_ELEMENT) &&
!source.includes('templateUrl')
) {
continue;
}
findings.push(
...(await findFormFieldsWithoutDefaults(file, source, (url) =>
readFile(path.resolve(rootDir, path.dirname(file), url), 'utf8')
))
...(await checkComponentSource(file, source, readTemplate))
);
}
if (findings.length > 0) {
console.error(
`Components that render <mat-form-field> without ${DEFAULTS_MODULE} (from ${DEFAULTS_ALIAS}), so their fields lose the app defaults (outlined, dynamic subscript). Import it in place of MatFormFieldModule:`
'Form-field defaults must be provided per component, not in the root injector:'
);
for (const { file, line, className } of findings) {
console.error(`- ${file}:${line} ${className}`);
for (const { file, line, reason } of findings) {
console.error(`- ${file}:${line} ${reason}`);
}
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} renderer sources; every component rendering <mat-form-field> imports ${DEFAULTS_MODULE}.`
`Checked ${files.length} renderer files; every <mat-form-field> component provides the app defaults.`
);
}
}
+179 -197
View File
@@ -1,218 +1,200 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import {
componentDeclarations,
findFormFieldsWithoutDefaults,
DEFAULTS_FILE,
checkComponentSource,
listScannedFiles,
templateRendersFormField,
} from './check-form-field-defaults.mjs';
const lines = (...rows) => rows.join('\n');
const component = ({
name = 'FieldComponent',
imports = ['AppFormFieldModule'],
template,
templateUrl,
importLine = "import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';",
}) =>
lines(
"import { Component } from '@angular/core';",
importLine,
'',
'@Component({',
` selector: 'app-field',`,
` imports: [${imports.join(', ')}],`,
template !== undefined
? ` template: \`${template}\`,`
: ` templateUrl: '${templateUrl}',`,
'})',
`export class ${name} {}`
);
const noTemplates = () => {
throw new Error('no templateUrl expected');
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const templates = {
'libs/a/field.component.html':
'<mat-form-field><input matInput /></mat-form-field>',
'libs/a/plain.component.html': '<button mat-button>Save</button>',
};
test('accepts a form field in a component that imports the defaults module', async () => {
const source = component({
template: '<mat-form-field><input matInput /></mat-form-field>',
});
assert.deepEqual(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
),
[]
const readTemplate = async (file) => templates[file];
const check = async (file, source) =>
(await checkComponentSource(file, source, readTemplate)).map(
({ line, reason }) => ({ line, reason })
);
test('accepts a templateUrl form field with the provider', async () => {
const source = [
'@Component({',
" selector: 'app-field',",
' providers: [provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('reports the line of the @Component decorator when the defaults module is missing', async () => {
const source = component({
imports: ['MatFormFieldModule'],
importLine:
"import { MatFormFieldModule } from '@angular/material/form-field';",
template: '<mat-form-field><input matInput /></mat-form-field>',
});
assert.deepEqual(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
),
[{ file: 'a.component.ts', line: 4, className: 'FieldComponent' }]
);
test('accepts the provider next to other providers', async () => {
const source = [
'@Component({',
' providers: [DatePipe, provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('a same-named class from elsewhere does not count as the defaults module', async () => {
const source = component({
importLine: "import { AppFormFieldModule } from './local-module';",
template: '<mat-form-field></mat-form-field>',
});
assert.equal(
(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
)
).length,
1
);
});
test('reports a templateUrl form field without the provider', async () => {
const source = [
'@Component({',
" selector: 'app-field',",
" templateUrl: './field.component.html',",
'})',
].join('\n');
test('a relative import of the module source counts inside its own library', async () => {
const source = component({
importLine:
"import { AppFormFieldModule } from '../../form-field/app-form-field.module';",
template: '<mat-form-field></mat-form-field>',
});
assert.deepEqual(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
),
[]
);
});
test('reads templateUrl templates and sees form fields inside control flow', async () => {
const source = component({
imports: ['MatInputModule'],
importLine: "import { MatInputModule } from '@angular/material/input';",
templateUrl: './a.component.html',
});
const urls = [];
const findings = await findFormFieldsWithoutDefaults(
'dir/a.component.ts',
source,
async (url) => {
urls.push(url);
return lines(
'@if (editing()) {',
' <mat-form-field appearance="fill">',
' <input matInput />',
' </mat-form-field>',
'}'
);
}
);
assert.deepEqual(urls, ['./a.component.html']);
assert.equal(findings.length, 1);
});
test('ignores components without a form field and classes without a template', async () => {
const source = lines(
component({
imports: ['MatButtonModule'],
importLine:
"import { MatButtonModule } from '@angular/material/button';",
template: '<button mat-button>Save</button>',
}),
'@Injectable({ providedIn: "root" })',
'export class PlainService {}'
);
assert.deepEqual(
await findFormFieldsWithoutDefaults('a.ts', source, noTemplates),
[]
);
});
test('templateRendersFormField does not match a form field inside a comment', () => {
assert.equal(
templateRendersFormField(
'<!-- <mat-form-field></mat-form-field> -->',
'a.html'
),
false
);
assert.equal(
templateRendersFormField(
'<div><ng-template><mat-form-field /></ng-template></div>',
'a.html'
),
true
);
});
test('componentDeclarations flattens nested import arrays and keeps the template url', () => {
const { components, bindsDefaultsModule } = componentDeclarations(
lines(
"import { Component } from '@angular/core';",
"import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';",
'const SHARED = [AppFormFieldModule];',
'@Component({',
' imports: [[MatInputModule, SHARED], AppFormFieldModule],',
" templateUrl: './x.html',",
'})',
'export class XComponent {}'
),
'x.ts'
);
assert.equal(bindsDefaultsModule, true);
assert.deepEqual(components, [
assert.deepEqual(await check('libs/a/field.component.ts', source), [
{
className: 'XComponent',
line: 4,
template: undefined,
templateUrl: './x.html',
imports: ['MatInputModule', 'SHARED', 'AppFormFieldModule'],
line: 1,
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
},
]);
});
test('listScannedFiles returns tracked renderer sources and skips specs', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'form-field-guard-'));
try {
await mkdir(path.join(root, 'libs/ui/src'), { recursive: true });
await mkdir(path.join(root, 'apps/web/src'), { recursive: true });
await writeFile(path.join(root, 'libs/ui/src/a.component.ts'), '');
await writeFile(path.join(root, 'libs/ui/src/a.component.spec.ts'), '');
await writeFile(path.join(root, 'apps/web/src/b.component.ts'), '');
await writeFile(path.join(root, 'apps/web/src/untracked.ts'), '');
execFileSync('git', ['init', '-q'], { cwd: root });
execFileSync(
'git',
[
'add',
'libs/ui/src/a.component.ts',
'libs/ui/src/a.component.spec.ts',
'apps/web/src/b.component.ts',
],
{ cwd: root }
);
assert.deepEqual(listScannedFiles(root).sort(), [
'apps/web/src/b.component.ts',
'libs/ui/src/a.component.ts',
]);
} finally {
await rm(root, { recursive: true, force: true });
}
test('reports an inline template form field without the provider', async () => {
const source = [
'@Component({',
' template: `',
' <mat-form-field appearance="outline"></mat-form-field>',
' `,',
'})',
].join('\n');
assert.equal((await check('libs/a/inline.component.ts', source)).length, 1);
});
test('reads quoted inline templates', async () => {
const source = [
'@Component({',
" template: '<mat-form-field></mat-form-field>',",
'})',
'class Single {}',
'@Component({',
' template: "<mat-form-field></mat-form-field>",',
'})',
'class Double {}',
].join('\n');
assert.deepEqual(
(await check('libs/a/quoted.component.ts', source)).map(
({ line }) => line
),
[1, 5]
);
});
test('checks each component of a file on its own providers', async () => {
const source = [
'@Component({',
' providers: [provideFormFieldDefaults()],',
" templateUrl: './field.component.html',",
'})',
'class WithDefaults {}',
'@Component({',
" templateUrl: './field.component.html',",
'})',
'class WithoutDefaults {}',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), [
{
line: 6,
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
},
]);
});
test('finds the provider after an earlier array-valued provider', async () => {
const source = [
'@Component({',
' providers: [',
' { provide: LIST, useValue: [] },',
' provideFormFieldDefaults(),',
' ],',
" templateUrl: './field.component.html',",
'})',
].join('\n');
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
});
test('reports the provider outside a component', async () => {
const appConfig = [
'export const appConfig = {',
' providers: [provideRouter(routes), provideFormFieldDefaults()],',
'};',
].join('\n');
const routes = [
'export const routes = [',
" { path: 'settings', providers: [provideFormFieldDefaults()] },",
'];',
].join('\n');
assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [
{
line: 2,
reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup",
},
]);
assert.equal(
(await check('apps/web/src/app/app.routes.ts', routes)).length,
1
);
});
test('ignores templates without a form field', async () => {
const source = [
'@Component({',
" templateUrl: './plain.component.html',",
'})',
'@Component({ template: `<mat-icon>add</mat-icon>` })',
].join('\n');
assert.deepEqual(await check('libs/a/plain.component.ts', source), []);
});
test('reports the token outside the shared provider', async () => {
const source = [
"import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';",
'export const appConfig = {',
' providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {} }],',
'};',
].join('\n');
assert.deepEqual(
(await check('apps/web/src/app/app.config.ts', source)).map(
({ line }) => line
),
[1, 3]
);
assert.deepEqual(await check(DEFAULTS_FILE, source), []);
});
test('the shared provider supplies the outline, dynamic defaults', async () => {
const source = await readFile(path.join(repoRoot, DEFAULTS_FILE), 'utf8');
assert.match(source, /appearance: 'outline'/);
assert.match(source, /subscriptSizing: 'dynamic'/);
assert.match(source, /provide: MAT_FORM_FIELD_DEFAULT_OPTIONS/);
});
test('scans renderer TypeScript and skips specs', () => {
const files = listScannedFiles(repoRoot);
assert.ok(files.includes(DEFAULTS_FILE));
assert.ok(files.includes('apps/web/src/app/app.config.ts'));
assert.ok(files.every((file) => !/\.spec\.ts$/.test(file)));
});
+2 -2
View File
@@ -105,8 +105,8 @@
"@iptvnator/ui/components/confirm-dialog": [
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
],
"@iptvnator/ui/components/form-field": [
"libs/ui/components/src/lib/form-field/app-form-field.module.ts"
"@iptvnator/ui/components/form-field-defaults": [
"libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts"
],
"@iptvnator/ui/components/lazy-dialog": [
"libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts"