From 715600d044eef00bb9d6ac882bc697d05ef12531 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 9 Jul 2026 10:44:37 +0300 Subject: [PATCH 01/10] fix: first iteration --- angular.json | 8 +- apps/docs/src/app/app.component.ts | 5 +- .../components/anchors/_anchors-theme.scss | 13 +-- .../components/anchors/anchors.component.html | 2 +- .../components/anchors/anchors.component.ts | 18 +-- .../components/code-snippet/code-snippet.ts | 18 +-- .../component-viewer-wrapper.ts | 3 +- .../component-viewer.component.ts | 17 +-- .../app/components/copy-button/copy-button.ts | 49 ++++---- .../design-tokens-viewers/tokens-overview.ts | 15 ++- .../typography-overview.ts | 3 +- .../example-viewer/example-viewer.ts | 26 ++--- .../icon-preview-modal.component.ts | 13 +-- .../icons-viewer/icons-viewer.component.ts | 38 +++---- .../icons-viewer/icons-viewer.template.html | 5 +- .../docs-live-example-viewer.html | 11 +- .../docs-live-example-viewer.scss | 4 +- .../docs-live-example-viewer.ts | 4 + .../live-example/docs-live-example.ts | 31 +++--- .../live-example/markdown-content.ts | 41 +++++++ .../app/components/navbar/navbar.component.ts | 22 +++- .../components/navbar/navbar.template.html | 2 +- .../page-not-found.component.ts | 4 +- .../select-example.component.ts | 5 +- .../stackblitz/stackblitz-button.ts | 3 +- .../stackblitz/stackblitz-writer.ts | 15 ++- .../version-picker.directive.ts | 8 ++ .../components/welcome/welcome.component.ts | 3 +- apps/docs/src/app/config.ts | 8 +- apps/docs/src/app/routes.ts | 99 +++++++++++------ apps/docs/src/app/services/analytics.ts | 76 +++++++++++++ apps/docs/src/app/services/clipboard.ts | 35 ++++++ apps/docs/src/app/services/doc-states.spec.ts | 89 +++++++++++++++ apps/docs/src/app/services/doc-states.ts | 35 ++++-- .../src/app/services/document-loader.spec.ts | 51 +++++++++ apps/docs/src/app/services/document-loader.ts | 18 ++- .../services/i18n.characterization.spec.ts | 105 ++++++++++++++++++ apps/docs/src/app/services/i18n.ts | 20 ++++ apps/docs/src/app/services/locale.spec.ts | 69 ++++++++++++ apps/docs/src/app/services/locale.ts | 31 ++++-- apps/docs/src/app/services/title-strategy.ts | 97 ++++++++++++++++ apps/docs/src/app/structure.spec.ts | 56 ++++++++++ apps/docs/src/app/structure.ts | 18 ++- apps/docs/src/config.server.ts | 8 +- .../docs/src/environments/environment.prod.ts | 4 - apps/docs/src/environments/environment.ts | 9 -- apps/docs/src/index.html | 29 +---- eslint.config.js | 13 +++ tsconfig.json | 3 +- 49 files changed, 995 insertions(+), 264 deletions(-) create mode 100644 apps/docs/src/app/components/live-example/markdown-content.ts create mode 100644 apps/docs/src/app/services/analytics.ts create mode 100644 apps/docs/src/app/services/clipboard.ts create mode 100644 apps/docs/src/app/services/doc-states.spec.ts create mode 100644 apps/docs/src/app/services/document-loader.spec.ts create mode 100644 apps/docs/src/app/services/i18n.characterization.spec.ts create mode 100644 apps/docs/src/app/services/i18n.ts create mode 100644 apps/docs/src/app/services/locale.spec.ts create mode 100644 apps/docs/src/app/services/title-strategy.ts create mode 100644 apps/docs/src/app/structure.spec.ts delete mode 100644 apps/docs/src/environments/environment.prod.ts delete mode 100644 apps/docs/src/environments/environment.ts diff --git a/angular.json b/angular.json index b17adcee0f..23cda17e22 100644 --- a/angular.json +++ b/angular.json @@ -113,13 +113,7 @@ "configurations": { "production": { "outputHashing": "all", - "tsConfig": "apps/docs/tsconfig.build.json", - "fileReplacements": [ - { - "replace": "apps/docs/src/environments/environment.ts", - "with": "apps/docs/src/environments/environment.prod.ts" - } - ] + "tsConfig": "apps/docs/tsconfig.build.json" }, "development": { "optimization": false, diff --git a/apps/docs/src/app/app.component.ts b/apps/docs/src/app/app.component.ts index 5467f983bc..d4c1c5a6b9 100644 --- a/apps/docs/src/app/app.component.ts +++ b/apps/docs/src/app/app.component.ts @@ -1,6 +1,6 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; import { AsyncPipe } from '@angular/common'; -import { Component, inject, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { KbqDividerModule } from '@koobiq/components/divider'; @@ -20,6 +20,7 @@ import { DocsDocStates, DocsNavbarState } from './services/doc-states'; ], templateUrl: 'app.component.html', styleUrl: 'app.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-app' @@ -63,7 +64,7 @@ export class DocsAppComponent { map((state) => state === DocsNavbarState.Opened) ); - isExamplesPage = toSignal( + readonly isExamplesPage = toSignal( this.router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd), map((event) => event.urlAfterRedirects.startsWith('/examples')) diff --git a/apps/docs/src/app/components/anchors/_anchors-theme.scss b/apps/docs/src/app/components/anchors/_anchors-theme.scss index 71f2b2bd1b..4b9206118b 100644 --- a/apps/docs/src/app/components/anchors/_anchors-theme.scss +++ b/apps/docs/src/app/components/anchors/_anchors-theme.scss @@ -21,23 +21,16 @@ .docs-anchors__element { &:first-child { - padding: 0 0 0 16px; + padding: 0 0 0 var(--kbq-size-l); margin: 0; & .docs-anchors__link { - padding: 6px 16px 6px 0; + padding: var(--kbq-size-xs) var(--kbq-size-l) var(--kbq-size-xs) 0; } } &.docs-anchors__element_active { - @include box-shadow(inset 3px 0 0 #363949); + box-shadow: inset 3px 0 0 $shadow; } } } - -@mixin box-shadow($shadow) { - -webkit-box-shadow: $shadow; - -moz-box-shadow: $shadow; - -o-box-shadow: $shadow; - box-shadow: $shadow; -} diff --git a/apps/docs/src/app/components/anchors/anchors.component.html b/apps/docs/src/app/components/anchors/anchors.component.html index ae84dea54c..7ebad71bf0 100644 --- a/apps/docs/src/app/components/anchors/anchors.component.html +++ b/apps/docs/src/app/components/anchors/anchors.component.html @@ -1,7 +1,7 @@ @for (anchor of anchors; track anchor) {
(classList).find((name) => name.startsWith('kbq-markdown__')) || ''; - return [ - 'kbq-markdown__h2', - 'kbq-markdown__h3', - 'kbq-markdown__h4', - 'kbq-markdown__h5' - ].indexOf(className); + return DOCS_MARKDOWN_HEADING_CLASSES.indexOf(className); } private onScroll = () => { diff --git a/apps/docs/src/app/components/code-snippet/code-snippet.ts b/apps/docs/src/app/components/code-snippet/code-snippet.ts index 35ad1bbb13..fd90e826e8 100644 --- a/apps/docs/src/app/components/code-snippet/code-snippet.ts +++ b/apps/docs/src/app/components/code-snippet/code-snippet.ts @@ -1,7 +1,5 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { Directive, ElementRef, inject } from '@angular/core'; -import { KbqToastService } from '@koobiq/components/toast'; -import { DocsLocaleState } from 'src/app/services/locale'; +import { DocsClipboardService } from 'src/app/services/clipboard'; @Directive({ selector: '[docsCodeSnippet]', @@ -10,19 +8,11 @@ import { DocsLocaleState } from 'src/app/services/locale'; '(click)': 'copy()' } }) -export class DocsCodeSnippetDirective extends DocsLocaleState { - private readonly clipboard = inject(Clipboard); - private readonly toastService = inject(KbqToastService); +export class DocsCodeSnippetDirective { + private readonly clipboard = inject(DocsClipboardService); private readonly elementRef = inject>(ElementRef); copy() { - if (!this.clipboard.copy(this.elementRef.nativeElement.textContent || '')) { - return; - } - - this.toastService.show({ - style: 'success', - title: this.isRuLocale() ? 'Скопировано' : 'Copied' - }); + this.clipboard.copyWithToast(this.elementRef.nativeElement.textContent || ''); } } diff --git a/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts b/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts index 9759b03d24..77c02d9208 100644 --- a/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts +++ b/apps/docs/src/app/components/component-viewer/component-viewer-wrapper.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqLinkModule } from '@koobiq/components/link'; import { DocsAnchorsComponent } from '../anchors/anchors.component'; @@ -39,6 +39,7 @@ import { DocsOverviewComponentBase } from './component-viewer.component';
`, + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'docs-component-overview' } diff --git a/apps/docs/src/app/components/component-viewer/component-viewer.component.ts b/apps/docs/src/app/components/component-viewer/component-viewer.component.ts index d44523c98a..511f2eeba7 100644 --- a/apps/docs/src/app/components/component-viewer/component-viewer.component.ts +++ b/apps/docs/src/app/components/component-viewer/component-viewer.component.ts @@ -10,7 +10,6 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet, UrlSegment } from '@angular/router'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqLinkModule } from '@koobiq/components/link'; @@ -84,9 +83,11 @@ export class DocsComponentViewerComponent extends DocsLocaleState { if (!docItem) { this.router.navigate(['/404']); + + return; } - this.structureItem = docItem!; + this.structureItem = docItem; this.structureCategoryId = docsGetCategoryById(this.structureItem.categoryId!)!.id; }); @@ -98,7 +99,6 @@ export class DocsComponentViewerComponent extends DocsLocaleState { export class DocsOverviewComponentBase extends DocsLocaleState { private readonly activatedRoute = inject(ActivatedRoute); private readonly changeDetectorRef = inject(ChangeDetectorRef); - private readonly titleService = inject(Title); componentDocItem: DocsStructureItem | null = null; @@ -136,15 +136,8 @@ export class DocsOverviewComponentBase extends DocsLocaleState { } private showView() { - const documentName = this.componentDocItem?.id; - let title = 'Koobiq'; - - if (documentName) { - title = `${documentName.charAt(0).toUpperCase()}${documentName.slice(1)} \u00B7 ${title}`; - } - - this.titleService.setTitle(title); - + // The page title/meta are owned centrally by `DocsTitleStrategy`; here we only flush the + // view once the document content has rendered. this.changeDetectorRef.detectChanges(); } } diff --git a/apps/docs/src/app/components/copy-button/copy-button.ts b/apps/docs/src/app/components/copy-button/copy-button.ts index da73696904..b58f67bc63 100644 --- a/apps/docs/src/app/components/copy-button/copy-button.ts +++ b/apps/docs/src/app/components/copy-button/copy-button.ts @@ -1,9 +1,11 @@ -import { Clipboard } from '@angular/cdk/clipboard'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input, signal, ViewEncapsulation } from '@angular/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; +import { DocsClipboardService } from 'src/app/services/clipboard'; import { DocsLocaleState } from 'src/app/services/locale'; +const SUCCESS_LABEL_DURATION = 1000; + @Component({ selector: 'docs-copy-button', imports: [ @@ -11,16 +13,23 @@ import { DocsLocaleState } from 'src/app/services/locale'; KbqLinkModule ], template: ` - @if (isLabelSuccessVisible) { - - - {{ isRuLocale() ? 'Скопировано' : 'Copied' }} - - } @else { - - - - } + + @if (isLabelSuccessVisible()) { + + {{ t('copied') }} + } @else { + + } + `, styleUrls: ['./copy-button.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,23 +39,19 @@ import { DocsLocaleState } from 'src/app/services/locale'; } }) export class DocsCopyButtonComponent extends DocsLocaleState { - @Input() contentToCopy: string; + readonly contentToCopy = input(''); - isLabelSuccessVisible = false; + protected readonly isLabelSuccessVisible = signal(false); - private readonly clipboard = inject(Clipboard); - private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly clipboard = inject(DocsClipboardService); copyContent(): void { - if (!this.contentToCopy || !this.clipboard.copy(this.contentToCopy)) { + if (!this.contentToCopy() || !this.clipboard.copy(this.contentToCopy())) { return; } - this.isLabelSuccessVisible = true; + this.isLabelSuccessVisible.set(true); - setTimeout(() => { - this.isLabelSuccessVisible = false; - this.changeDetectorRef.markForCheck(); - }, 1000); + setTimeout(() => this.isLabelSuccessVisible.set(false), SUCCESS_LABEL_DURATION); } } diff --git a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts index 17b6bae1f9..5367c50de6 100644 --- a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts +++ b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts @@ -1,5 +1,14 @@ import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; -import { afterNextRender, AfterViewInit, Component, inject, input, signal, viewChild } from '@angular/core'; +import { + afterNextRender, + AfterViewInit, + ChangeDetectionStrategy, + Component, + inject, + input, + signal, + viewChild +} from '@angular/core'; import { KBQ_WINDOW, ThemeService } from '@koobiq/components/core'; import { KbqTableModule } from '@koobiq/components/table'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; @@ -93,7 +102,8 @@ interface DocsTokensInfoRaw { } `, - styleUrls: ['design-tokens.scss'] + styleUrls: ['design-tokens.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class DocsTokensTable extends DocsLocaleState { readonly section = input(); @@ -196,6 +206,7 @@ export class DocsTokensTable extends DocsLocaleState { `, + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'kbq-markdown' } diff --git a/apps/docs/src/app/components/design-tokens-viewers/typography-overview.ts b/apps/docs/src/app/components/design-tokens-viewers/typography-overview.ts index 81350cf354..443473f5c0 100644 --- a/apps/docs/src/app/components/design-tokens-viewers/typography-overview.ts +++ b/apps/docs/src/app/components/design-tokens-viewers/typography-overview.ts @@ -1,4 +1,4 @@ -import { Component, signal, viewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; import { DocsLocaleState } from '../../services/locale'; import { DocsCodeSnippetDirective } from '../code-snippet/code-snippet'; @@ -49,6 +49,7 @@ import { docsData } from './data/typography'; `, styleUrls: ['design-tokens.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'kbq-markdown' } diff --git a/apps/docs/src/app/components/example-viewer/example-viewer.ts b/apps/docs/src/app/components/example-viewer/example-viewer.ts index 1275103b89..677413f55e 100644 --- a/apps/docs/src/app/components/example-viewer/example-viewer.ts +++ b/apps/docs/src/app/components/example-viewer/example-viewer.ts @@ -4,6 +4,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { afterNextRender, ApplicationRef, + ChangeDetectionStrategy, Component, ElementRef, EventEmitter, @@ -14,8 +15,8 @@ import { OnDestroy, Output, PLATFORM_ID, - SecurityContext, signal, + Type, ViewContainerRef } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @@ -25,6 +26,7 @@ import { take } from 'rxjs/operators'; import { DocsLocaleState } from 'src/app/services/locale'; import { DocsDocumentLoader } from '../../services/document-loader'; import { DocsLiveExampleViewerComponent } from '../live-example-viewer/docs-live-example-viewer'; +import { docsBuildDocumentErrorHtml, docsRewriteFragmentUrls } from '../live-example/markdown-content'; @Component({ selector: 'docs-example-viewer', @@ -36,6 +38,7 @@ import { DocsLiveExampleViewerComponent } from '../live-example-viewer/docs-live {{ isRuLocale() ? 'Загрузка документа...' : 'Loading document...' }} } `, + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'docs-live-example kbq-markdown' } @@ -87,10 +90,10 @@ export class DocsExampleViewerComponent extends DocsLocaleState implements OnDes private getDocument(url: string) { this.documentFetchSubscription?.unsubscribe(); - this.documentFetchSubscription = this.documentLoader.get(url).subscribe( - (document) => this.updateDocument(document), - (error) => this.showError(url, error) - ); + this.documentFetchSubscription = this.documentLoader.get(url).subscribe({ + next: (document) => this.updateDocument(document), + error: (error) => this.showError(url, error) + }); } /** @@ -101,11 +104,7 @@ export class DocsExampleViewerComponent extends DocsLocaleState implements OnDes // Replace all relative fragment URLs with absolute fragment URLs. e.g. "#my-section" becomes // "/components/button/api#my-section". This is necessary because otherwise these fragment // links would redirect to "/#my-section". - rawDocument = rawDocument.replace(/href="#([^"]*)"/g, (_m: string, fragmentUrl: string) => { - const absoluteUrl = `${this.window.location.pathname}#${fragmentUrl}`; - - return `href="${this.domSanitizer.sanitize(SecurityContext.URL, absoluteUrl)}"`; - }); + rawDocument = docsRewriteFragmentUrls(rawDocument, this.domSanitizer, this.window.location.pathname); this.documentContent.set(this.domSanitizer.bypassSecurityTrustHtml(rawDocument)); @@ -127,9 +126,8 @@ export class DocsExampleViewerComponent extends DocsLocaleState implements OnDes /** Show an error that occurred when fetching a document. */ private showError(url: string, error: HttpErrorResponse) { console.error(error); - const errorHtml = this.isRuLocale() - ? `Не удалось загрузить документ: ${url}. Ошибка: ${error.statusText}. Создать issue` - : `Failed to load document: ${url}. Error: ${error.statusText}. Create issue`; + + const errorHtml = docsBuildDocumentErrorHtml(url, error.statusText, this.isRuLocale()); this.documentContent.set(this.domSanitizer.bypassSecurityTrustHtml(errorHtml)); @@ -139,7 +137,7 @@ export class DocsExampleViewerComponent extends DocsLocaleState implements OnDes } /** Instantiate a ExampleViewer for each example. */ - private loadComponents(componentName: string, componentClass: any) { + private loadComponents(componentName: string, componentClass: Type) { const exampleElements = this.elementRef.nativeElement.querySelectorAll(`[${componentName}]`); exampleElements.forEach((element: Element) => { diff --git a/apps/docs/src/app/components/icons-viewer/icon-preview-modal/icon-preview-modal.component.ts b/apps/docs/src/app/components/icons-viewer/icon-preview-modal/icon-preview-modal.component.ts index 16b00c2e2f..523cfd75cd 100644 --- a/apps/docs/src/app/components/icons-viewer/icon-preview-modal/icon-preview-modal.component.ts +++ b/apps/docs/src/app/components/icons-viewer/icon-preview-modal/icon-preview-modal.component.ts @@ -1,4 +1,3 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { DOCUMENT, TitleCasePipe } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { @@ -18,8 +17,8 @@ import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIcon, KbqIconModule } from '@koobiq/components/icon'; import { KBQ_MODAL_DATA, KbqModalModule, KbqModalRef } from '@koobiq/components/modal'; import { KbqSelectModule } from '@koobiq/components/select'; -import { KbqToastService } from '@koobiq/components/toast'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; +import { DocsClipboardService } from 'src/app/services/clipboard'; import { DocsIconItem } from 'src/app/services/icon-items'; import { DocsLocaleState } from 'src/app/services/locale'; import { DocsCodeSnippetDirective } from '../../code-snippet/code-snippet'; @@ -50,8 +49,7 @@ export type DocsIconPreviewModalData = { export class DocsIconPreviewModalComponent extends DocsLocaleState { private readonly icon = viewChild.required('iconPreview', { read: KbqIcon }); - private readonly clipboard = inject(Clipboard); - private readonly toastService = inject(KbqToastService); + private readonly clipboard = inject(DocsClipboardService); private readonly httpClient = inject(HttpClient); protected readonly modal = inject(KbqModalRef); private readonly window = inject(KBQ_WINDOW); @@ -82,12 +80,7 @@ export class DocsIconPreviewModalComponent extends DocsLocaleState { protected copySVG(): void { this.httpClient.get(this.svgLink, { responseType: 'text' }).subscribe((data) => { - if (this.clipboard.copy(this.setSvgFillColor(data, this.getComputedIconColor() || 'currentColor'))) { - this.toastService.show({ - style: 'success', - title: this.isRuLocale() ? 'Скопировано' : 'Copied' - }); - } + this.clipboard.copyWithToast(this.setSvgFillColor(data, this.getComputedIconColor() || 'currentColor')); }); } diff --git a/apps/docs/src/app/components/icons-viewer/icons-viewer.component.ts b/apps/docs/src/app/components/icons-viewer/icons-viewer.component.ts index e8063c1027..685a4e54db 100644 --- a/apps/docs/src/app/components/icons-viewer/icons-viewer.component.ts +++ b/apps/docs/src/app/components/icons-viewer/icons-viewer.component.ts @@ -11,7 +11,6 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { KbqHighlightModule, ThemePalette } from '@koobiq/components/core'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; @@ -59,7 +58,6 @@ export class DocsIconsViewerComponent extends DocsLocaleState { private readonly activeRoute = inject(ActivatedRoute); private readonly router = inject(Router); private readonly location = inject(Location); - private readonly titleService = inject(Title); private readonly docStates = inject(DocsDocStates); private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly elementRef = inject>(ElementRef); @@ -73,25 +71,31 @@ export class DocsIconsViewerComponent extends DocsLocaleState { availableSizes: number[]; private iconItems: DocsIconItems; - private queryParamMap: Params; + private queryParamMap: Params = {}; constructor() { super(); - this.http.get('assets/SVGIcons/kbq-icons-info.json', { responseType: 'json' }).subscribe((data) => { - this.iconItems = new DocsIconItems( - data as Record - ); + this.http + .get('assets/SVGIcons/kbq-icons-info.json', { responseType: 'json' }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data) => { + this.iconItems = new DocsIconItems( + data as Record + ); - this.availableSizes = Array.from(this.iconItems.sizes).sort((a, b) => a - b); + this.availableSizes = Array.from(this.iconItems.sizes).sort((a, b) => a - b); - this.init(); - this.refreshTitle(undefined); + this.init(); - this.changeDetectorRef.markForCheck(); - }); + this.changeDetectorRef.markForCheck(); + }); + + // `Location` is a root/app-scoped service, so this popstate handler outlives the + // component unless it is torn down explicitly. + const locationSubscription = this.location.subscribe(() => this.modalService.closeAll()); - this.location.subscribe(() => this.modalService.closeAll()); + this.destroyRef.onDestroy(() => locationSubscription.unsubscribe()); this.activeRoute.queryParamMap.pipe(takeUntilDestroyed()).subscribe(({ params }: Params) => { this.searchControl.setValue(params.s); @@ -131,7 +135,7 @@ export class DocsIconsViewerComponent extends DocsLocaleState { ) .subscribe((filteredItems) => this.filteredIcons.next(filteredItems!)); - this.activeRoute.queryParams.subscribe((params) => { + this.activeRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { if (params.id) { const iconItem = this.iconItems.getItemById(params.id); @@ -160,8 +164,6 @@ export class DocsIconsViewerComponent extends DocsLocaleState { s: this.searchControl.value ? this.searchControl.value : undefined } }); - - this.refreshTitle(iconItem); } openIconPreview(iconItem: DocsIconItem): void { @@ -193,8 +195,4 @@ export class DocsIconsViewerComponent extends DocsLocaleState { queryParamsHandling: 'merge' }); } - - private refreshTitle(activeIcon: DocsIconItem | undefined): void { - this.titleService.setTitle(`${activeIcon ? `${activeIcon.name} \u00B7 ` : ''} Icon \u00B7 Mosaic`); - } } diff --git a/apps/docs/src/app/components/icons-viewer/icons-viewer.template.html b/apps/docs/src/app/components/icons-viewer/icons-viewer.template.html index 0e95e9290f..c57d6ac37a 100644 --- a/apps/docs/src/app/components/icons-viewer/icons-viewer.template.html +++ b/apps/docs/src/app/components/icons-viewer/icons-viewer.template.html @@ -20,9 +20,12 @@ @for (icon of filterBySize(icons, size); track icon) {
- + {{ isSourceShown() ? isRuLocale() diff --git a/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.scss b/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.scss index b7aaf52c9d..6c1feec451 100644 --- a/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.scss +++ b/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.scss @@ -6,7 +6,9 @@ border-radius: inherit; border-bottom-left-radius: 0; border-bottom-right-radius: 0; - background: var(--docs-live-example-example-background); + // `--docs-live-example-example-background` is only assigned by a few example modifier classes + // (see styles/_examples.scss); everything else falls back to a transparent canvas explicitly. + background: var(--docs-live-example-example-background, transparent); } .docs-live-example-viewer { diff --git a/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.ts b/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.ts index d8a78367ff..72457060ff 100644 --- a/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.ts +++ b/apps/docs/src/app/components/live-example-viewer/docs-live-example-viewer.ts @@ -1,5 +1,6 @@ import { NgComponentOutlet } from '@angular/common'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, @@ -51,6 +52,7 @@ interface ExampleFileData { ], templateUrl: './docs-live-example-viewer.html', styleUrls: ['./docs-live-example-viewer.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-live-example-viewer kbq-markdown' @@ -164,6 +166,8 @@ export class DocsLiveExampleViewerComponent extends DocsLocaleState { preferredExampleFileOrder.indexOf(a.language) - preferredExampleFileOrder.indexOf(b.language) ); this.files.push(...this.prepareCodeFiles(results)); + // Files arrive from async HTTP; under OnPush the code panel needs an explicit check. + this.cdr.markForCheck(); }, error: (error) => { console.error('Error fetching the files', error); diff --git a/apps/docs/src/app/components/live-example/docs-live-example.ts b/apps/docs/src/app/components/live-example/docs-live-example.ts index 929c0ae9b6..63ec3eb077 100644 --- a/apps/docs/src/app/components/live-example/docs-live-example.ts +++ b/apps/docs/src/app/components/live-example/docs-live-example.ts @@ -4,6 +4,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { afterNextRender, ApplicationRef, + ChangeDetectionStrategy, Component, ElementRef, EventEmitter, @@ -14,8 +15,8 @@ import { OnDestroy, Output, PLATFORM_ID, - SecurityContext, signal, + Type, ViewChild, ViewContainerRef } from '@angular/core'; @@ -31,6 +32,7 @@ import { DocsLocaleState } from 'src/app/services/locale'; import { DocsDocumentLoader } from '../../services/document-loader'; import { DocsCodeSnippetDirective } from '../code-snippet/code-snippet'; import { DocsLiveExampleViewerComponent } from '../live-example-viewer/docs-live-example-viewer'; +import { docsBuildDocumentErrorHtml, docsRewriteFragmentUrls } from './markdown-content'; @Component({ selector: 'docs-live-example', @@ -60,6 +62,7 @@ import { DocsLiveExampleViewerComponent } from '../live-example-viewer/docs-live > `, + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'docs-live-example kbq-markdown' } @@ -123,14 +126,7 @@ export class DocsLiveExampleComponent extends DocsLocaleState implements OnDestr * @param rawDocument The raw document content to show. */ private updateDocument(rawDocument: string) { - // Replace all relative fragment URLs with absolute fragment URLs. e.g. "#my-section" becomes - // "/components/button/api#my-section". This is necessary because otherwise these fragment - // links would redirect to "/#my-section". - rawDocument = rawDocument.replace(/href="#([^"]*)"/g, (_m: string, fragmentUrl: string) => { - const absoluteUrl = `${this.window.location.pathname}#${fragmentUrl}`; - - return `href="${this.domSanitizer.sanitize(SecurityContext.URL, absoluteUrl)}"`; - }); + rawDocument = docsRewriteFragmentUrls(rawDocument, this.domSanitizer, this.window.location.pathname); this.documentContent.set(this.domSanitizer.bypassSecurityTrustHtml(rawDocument)); @@ -155,9 +151,8 @@ export class DocsLiveExampleComponent extends DocsLocaleState implements OnDestr /** Show an error that occurred when fetching a document. */ private showError(url: string, error: HttpErrorResponse) { console.error(error); - const errorHtml = this.isRuLocale() - ? `Не удалось загрузить документ: ${url}. Ошибка: ${error.statusText}. Создать issue` - : `Failed to load document: ${url}. Error: ${error.statusText}. Create issue`; + + const errorHtml = docsBuildDocumentErrorHtml(url, error.statusText, this.isRuLocale()); this.documentContent.set(this.domSanitizer.bypassSecurityTrustHtml(errorHtml)); @@ -165,14 +160,14 @@ export class DocsLiveExampleComponent extends DocsLocaleState implements OnDestr } /** Instantiate a ExampleViewer for each example. */ - private loadComponents(componentName: string, componentClass: any) { + private loadComponents(componentName: string, componentClass: Type) { this.nativeElement.querySelectorAll(`[${componentName}]`).forEach((element: Element) => { const portalHost = new DomPortalOutlet(element, this.appRef, this.injector); - const examplePortal: ComponentPortal = new ComponentPortal(componentClass, this.viewContainerRef); + const examplePortal = new ComponentPortal(componentClass, this.viewContainerRef); const exampleViewer = portalHost.attach(examplePortal); - // todo проверить, что достается из атрибута ? - (exampleViewer.instance as DocsLiveExampleViewerComponent).example = element.getAttribute(componentName); + // TODO: verify what is read from the attribute. + exampleViewer.instance.example = element.getAttribute(componentName); this.portalHosts.push(portalHost); }); @@ -184,7 +179,7 @@ export class DocsLiveExampleComponent extends DocsLocaleState implements OnDestr this.nativeElement.querySelectorAll(`.${markDownClass}`).forEach((element: Element) => { const { outerHTML, textContent } = element; - element.innerHTML = ''; + element.replaceChildren(); const portalHost = new DomPortalOutlet(element, this.appRef, this.injector); @@ -206,7 +201,7 @@ export class DocsLiveExampleComponent extends DocsLocaleState implements OnDestr this.nativeElement.querySelectorAll(`[${selector}]`).forEach((element: Element) => { const { innerHTML, textContent } = element; - element.innerHTML = ''; + element.replaceChildren(); const portalHost = new DomPortalOutlet(element, this.appRef, this.injector); diff --git a/apps/docs/src/app/components/live-example/markdown-content.ts b/apps/docs/src/app/components/live-example/markdown-content.ts new file mode 100644 index 0000000000..f9276a2060 --- /dev/null +++ b/apps/docs/src/app/components/live-example/markdown-content.ts @@ -0,0 +1,41 @@ +import { SecurityContext } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; + +/** GitHub "new issue" URL surfaced when a document fails to load. */ +export const DOCS_GITHUB_ISSUE_URL = 'https://github.com/koobiq/angular-components/issues/new'; + +/** Escapes text so interpolated values cannot break out of an HTML string built for bypassed rendering. */ +export function docsEscapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Builds the "failed to load document" HTML. The dynamic `url`/`statusText` are HTML-escaped so a + * future refactor that makes them attacker-controlled cannot reintroduce an XSS sink through the + * surrounding `bypassSecurityTrustHtml`. + */ +export function docsBuildDocumentErrorHtml(url: string, statusText: string, isRuLocale: boolean): string { + const safeUrl = docsEscapeHtml(url); + const safeStatus = docsEscapeHtml(statusText ?? ''); + + return isRuLocale + ? `Не удалось загрузить документ: ${safeUrl}. Ошибка: ${safeStatus}. Создать issue` + : `Failed to load document: ${safeUrl}. Error: ${safeStatus}. Create issue`; +} + +/** + * Rewrites relative `href="#fragment"` links to absolute fragment URLs anchored at the current + * path, so they do not redirect to `/#fragment`. + */ +export function docsRewriteFragmentUrls(rawDocument: string, sanitizer: DomSanitizer, pathname: string): string { + return rawDocument.replace(/href="#([^"]*)"/g, (_m: string, fragmentUrl: string) => { + const absoluteUrl = `${pathname}#${fragmentUrl}`; + + return `href="${sanitizer.sanitize(SecurityContext.URL, absoluteUrl)}"`; + }); +} diff --git a/apps/docs/src/app/components/navbar/navbar.component.ts b/apps/docs/src/app/components/navbar/navbar.component.ts index 07b699af1b..178cdd0185 100644 --- a/apps/docs/src/app/components/navbar/navbar.component.ts +++ b/apps/docs/src/app/components/navbar/navbar.component.ts @@ -1,5 +1,13 @@ import { AsyncPipe } from '@angular/common'; -import { afterNextRender, ChangeDetectorRef, Component, inject, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { + afterNextRender, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + OnDestroy, + ViewEncapsulation +} from '@angular/core'; import { RouterLink } from '@angular/router'; import { KbqButtonModule } from '@koobiq/components/button'; import { KBQ_WINDOW, KbqTheme, KbqThemeSelector, ThemeService } from '@koobiq/components/core'; @@ -30,6 +38,7 @@ import { DocsNavbarProperty } from './navbar-property'; ], templateUrl: 'navbar.template.html', styleUrls: ['navbar.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-navbar' @@ -114,9 +123,8 @@ export class DocsNavbarComponent extends DocsLocaleState implements OnDestroy { } ngOnDestroy() { - // eslint-disable-next-line @angular-eslint/no-lifecycle-call - this.themeService.ngOnDestroy(); - + // NOTE: `ThemeService` is a root singleton and owns its own lifecycle — the navbar must not + // tear it down. Only this component's own media-query listener is removed here. try { this.colorAutomaticTheme.removeEventListener('change', this.setAutoTheme); } catch (err) { @@ -134,7 +142,7 @@ export class DocsNavbarComponent extends DocsLocaleState implements OnDestroy { this.themeService.setTheme(i); } - private setAutoTheme = (e) => { + private setAutoTheme = (e: MediaQueryListEvent) => { if (!this.themeService.themes[0]) return; this.themeService.themes[0] = { @@ -145,5 +153,9 @@ export class DocsNavbarComponent extends DocsLocaleState implements OnDestroy { if (this.themeService.themes[0].selected) { this.setTheme(0); } + + // The media-query listener runs outside Angular's event bindings, so trigger a check + // explicitly for the OnPush theme dropdown. + this.cdr.markForCheck(); }; } diff --git a/apps/docs/src/app/components/navbar/navbar.template.html b/apps/docs/src/app/components/navbar/navbar.template.html index 0d6ba87748..cd16ae67c0 100644 --- a/apps/docs/src/app/components/navbar/navbar.template.html +++ b/apps/docs/src/app/components/navbar/navbar.template.html @@ -15,7 +15,7 @@ [kbqStyle]="'transparent'" (click)="docStates.scrollUp()" > - {{ docStates.currentHeader.textContent }} + {{ docStates.currentHeader?.textContent }} }
diff --git a/apps/docs/src/app/components/page-not-found/page-not-found.component.ts b/apps/docs/src/app/components/page-not-found/page-not-found.component.ts index c7dcbb2873..6a2e316d14 100644 --- a/apps/docs/src/app/components/page-not-found/page-not-found.component.ts +++ b/apps/docs/src/app/components/page-not-found/page-not-found.component.ts @@ -10,10 +10,10 @@ import { DocsLocaleState } from 'src/app/services/locale'; RouterLink ], template: ` -
{{ isRuLocale() ? 'Страница не найдена' : 'Page not found' }}
+
{{ t('pageNotFound') }}
- {{ isRuLocale() ? 'Перейти на главную страницу' : 'Go to main page' }} + {{ t('goToMainPage') }} `, styleUrls: ['page-not-found.scss'], diff --git a/apps/docs/src/app/components/select-example/select-example.component.ts b/apps/docs/src/app/components/select-example/select-example.component.ts index 650c8c54d4..015f53a9fc 100644 --- a/apps/docs/src/app/components/select-example/select-example.component.ts +++ b/apps/docs/src/app/components/select-example/select-example.component.ts @@ -107,7 +107,4 @@ import { KbqTopBarModule } from '@koobiq/components/top-bar'; `, changeDetection: ChangeDetectionStrategy.OnPush }) -export class DocsSelectExample { - readonly popoverContent = - 'Think of a popover as a lightweight, floating window that is directly tethered to the element that summoned it. Unlike a modal dialog, it doesn\'t usually block interaction with the rest of the page entirely, allowing for a faster, more "in-context" workflow. It\'s often activated by clicking a button, icon, or link, and it disappears when the user moves their focus away.'; -} +export class DocsSelectExample {} diff --git a/apps/docs/src/app/components/stackblitz/stackblitz-button.ts b/apps/docs/src/app/components/stackblitz/stackblitz-button.ts index 8bff0927bb..5addc76816 100644 --- a/apps/docs/src/app/components/stackblitz/stackblitz-button.ts +++ b/apps/docs/src/app/components/stackblitz/stackblitz-button.ts @@ -1,4 +1,4 @@ -import { Component, Input, ViewEncapsulation, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, ViewEncapsulation, inject } from '@angular/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLink, KbqLinkModule } from '@koobiq/components/link'; import { ExampleData } from '@koobiq/docs-examples'; @@ -11,6 +11,7 @@ import { DocsStackblitzWriter } from './stackblitz-writer'; StackBlitz `, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-stackblitz-button kbq-link_external', diff --git a/apps/docs/src/app/components/stackblitz/stackblitz-writer.ts b/apps/docs/src/app/components/stackblitz/stackblitz-writer.ts index 2468e43cd9..c122433af7 100644 --- a/apps/docs/src/app/components/stackblitz/stackblitz-writer.ts +++ b/apps/docs/src/app/components/stackblitz/stackblitz-writer.ts @@ -69,9 +69,8 @@ export class DocsStackblitzWriter { */ createStackBlitzForExample(exampleId: string, data: ExampleData): Promise<() => void> { if (!this.cache.has(exampleId)) { - this.cache.set( - exampleId, - this.buildInMemoryProject(data, exampleId).then(({ files, dependencies }) => () => { + const project = this.buildInMemoryProject(data, exampleId) + .then(({ files, dependencies }) => () => { StackBlitzSDK.openProject( { title: `Angular Components - ${data.description}`, @@ -84,7 +83,15 @@ export class DocsStackblitzWriter { { openFile: `src/example/${data.indexFilename}` } ); }) - ); + .catch((error) => { + // Evict the rejected promise so a failed file fetch does not permanently + // poison the cache — the next click on this example re-fetches. + this.cache.delete(exampleId); + + throw error; + }); + + this.cache.set(exampleId, project); } return this.cache.get(exampleId)!; diff --git a/apps/docs/src/app/components/version-picker/version-picker.directive.ts b/apps/docs/src/app/components/version-picker/version-picker.directive.ts index 6f79c7ee50..18e0c51f34 100644 --- a/apps/docs/src/app/components/version-picker/version-picker.directive.ts +++ b/apps/docs/src/app/components/version-picker/version-picker.directive.ts @@ -1,6 +1,8 @@ import { HttpClient } from '@angular/common/http'; import { ChangeDetectorRef, Directive, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_WINDOW } from '@koobiq/components/core'; +import { catchError, of } from 'rxjs'; type DocsVersion = { version: string; @@ -27,6 +29,12 @@ export class DocsVersionPickerDirective { constructor() { this.httpClient .get('https://next.koobiq.io/assets/versions.json', { responseType: 'json' }) + .pipe( + // A failed version fetch must not throw an unhandled error; degrade gracefully to + // an empty version list (the picker simply shows no alternative versions). + catchError(() => of({})), + takeUntilDestroyed() + ) .subscribe((data) => { Object.entries(data) .reverse() diff --git a/apps/docs/src/app/components/welcome/welcome.component.ts b/apps/docs/src/app/components/welcome/welcome.component.ts index 27cdf88b05..025879891d 100644 --- a/apps/docs/src/app/components/welcome/welcome.component.ts +++ b/apps/docs/src/app/components/welcome/welcome.component.ts @@ -1,5 +1,5 @@ import { NgOptimizedImage } from '@angular/common'; -import { Component, ElementRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; import { ThemeService } from '@koobiq/components/core'; @@ -23,6 +23,7 @@ import { DocsRegisterHeaderDirective } from '../register-header/register-header. ], templateUrl: './welcome.component.html', styleUrls: ['./welcome.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'docs-welcome kbq-scrollbar' diff --git a/apps/docs/src/app/config.ts b/apps/docs/src/app/config.ts index 2e85eac88a..48d9b4e15e 100644 --- a/apps/docs/src/app/config.ts +++ b/apps/docs/src/app/config.ts @@ -2,9 +2,11 @@ import { provideHttpClient, withFetch } from '@angular/common/http'; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; +import { provideRouter, TitleStrategy } from '@angular/router'; import { KBQ_LOCALE_SERVICE, KbqLocaleService, kbqLocaleServiceLangAttrNameProvider } from '@koobiq/components/core'; import { DOCS_ROUTES } from './routes'; +import { docsProvideAnalytics } from './services/analytics'; +import { DocsTitleStrategy } from './services/title-strategy'; // eslint-disable-next-line @typescript-eslint/naming-convention export const appConfig: ApplicationConfig = { @@ -15,6 +17,8 @@ export const appConfig: ApplicationConfig = { provideRouter(DOCS_ROUTES), provideHttpClient(withFetch()), provideClientHydration(withEventReplay()), - provideAnimations() + provideAnimations(), + { provide: TitleStrategy, useClass: DocsTitleStrategy }, + docsProvideAnalytics() ] }; diff --git a/apps/docs/src/app/routes.ts b/apps/docs/src/app/routes.ts index 7c76e4a69f..d9ff1edfe0 100644 --- a/apps/docs/src/app/routes.ts +++ b/apps/docs/src/app/routes.ts @@ -1,19 +1,5 @@ import { inject } from '@angular/core'; import { CanMatchFn, Route, Routes, UrlSegment } from '@angular/router'; -import { - DocsComponentApiComponent, - DocsComponentExamplesComponent, - DocsComponentOverviewComponent, - DocsComponentViewerComponent -} from './components/component-viewer/component-viewer.component'; -import { DocsDesignTokensViewer } from './components/design-tokens-viewers/design-tokens-viewer'; -import { DocsTokensOverview } from './components/design-tokens-viewers/tokens-overview'; -import { DocsTypographyTable } from './components/design-tokens-viewers/typography-overview'; -import { DocsIconsViewerComponent } from './components/icons-viewer/icons-viewer.component'; -import { DocsPageNotFoundComponent } from './components/page-not-found/page-not-found.component'; -import { DocsPopoverExample } from './components/popover-example/popover-example.component'; -import { DocsSelectExample } from './components/select-example/select-example.component'; -import { DocsWelcomeComponent } from './components/welcome/welcome.component'; import { DOCS_DEFAULT_LOCALE } from './constants/locale'; import { DocsLocaleService } from './services/locale'; import { @@ -34,6 +20,25 @@ const canMatchLocaleRoutes: CanMatchFn = (_route: Route, segments: UrlSegment[]) return docsLocaleService.isSupportedLocale(path); }; +/** + * Lazy loaders. Route components are code-split with `loadComponent` so the heavy page graph + * (icons viewer, token tables, example viewers) is fetched on demand instead of being eagerly + * bundled into the initial SSR/hydration chunk. Loaders that resolve from the same module share + * a single chunk. + */ +const loadComponentViewer = () => + import('./components/component-viewer/component-viewer.component').then((m) => m.DocsComponentViewerComponent); +const loadComponentOverview = () => + import('./components/component-viewer/component-viewer.component').then((m) => m.DocsComponentOverviewComponent); +const loadComponentApi = () => + import('./components/component-viewer/component-viewer.component').then((m) => m.DocsComponentApiComponent); +const loadComponentExamples = () => + import('./components/component-viewer/component-viewer.component').then((m) => m.DocsComponentExamplesComponent); +const loadTokensOverview = () => + import('./components/design-tokens-viewers/tokens-overview').then((m) => m.DocsTokensOverview); +const loadTypographyTable = () => + import('./components/design-tokens-viewers/typography-overview').then((m) => m.DocsTypographyTable); + export const DOCS_ROUTES: Routes = [ { path: '', redirectTo: DOCS_DEFAULT_LOCALE, pathMatch: 'full' }, { @@ -42,7 +47,8 @@ export const DOCS_ROUTES: Routes = [ children: [ { path: '', - component: DocsWelcomeComponent, + loadComponent: () => + import('./components/welcome/welcome.component').then((m) => m.DocsWelcomeComponent), pathMatch: 'full' }, @@ -56,25 +62,28 @@ export const DOCS_ROUTES: Routes = [ }, { path: `${DocsStructureCategoryId.Main}/${DocsStructureItemId.DesignTokens}`, - component: DocsDesignTokensViewer, + loadComponent: () => + import('./components/design-tokens-viewers/design-tokens-viewer').then( + (m) => m.DocsDesignTokensViewer + ), children: [ { path: '', redirectTo: DocsStructureTokensTab.Colors, pathMatch: 'full' }, - { path: DocsStructureTokensTab.Colors, component: DocsTokensOverview, pathMatch: 'full' }, - { path: DocsStructureTokensTab.Typography, component: DocsTypographyTable, pathMatch: 'full' }, - { path: DocsStructureTokensTab.Shadows, component: DocsTokensOverview, pathMatch: 'full' }, - { path: DocsStructureTokensTab.BorderRadius, component: DocsTokensOverview, pathMatch: 'full' }, - { path: DocsStructureTokensTab.Sizes, component: DocsTokensOverview, pathMatch: 'full' }, - { path: DocsStructureTokensTab.Semantic, component: DocsTokensOverview, pathMatch: 'full' }, - { path: DocsStructureTokensTab.Palette, component: DocsTokensOverview, pathMatch: 'full' }, + { path: DocsStructureTokensTab.Colors, loadComponent: loadTokensOverview, pathMatch: 'full' }, + { path: DocsStructureTokensTab.Typography, loadComponent: loadTypographyTable, pathMatch: 'full' }, + { path: DocsStructureTokensTab.Shadows, loadComponent: loadTokensOverview, pathMatch: 'full' }, + { path: DocsStructureTokensTab.BorderRadius, loadComponent: loadTokensOverview, pathMatch: 'full' }, + { path: DocsStructureTokensTab.Sizes, loadComponent: loadTokensOverview, pathMatch: 'full' }, + { path: DocsStructureTokensTab.Semantic, loadComponent: loadTokensOverview, pathMatch: 'full' }, + { path: DocsStructureTokensTab.Palette, loadComponent: loadTokensOverview, pathMatch: 'full' }, { path: '**', redirectTo: DocsStructureTokensTab.Colors } ] }, { path: `${DocsStructureCategoryId.Main}/:id`, - component: DocsComponentViewerComponent, + loadComponent: loadComponentViewer, children: [ { path: '', redirectTo: DocsStructureItemTab.Overview, pathMatch: 'full' }, - { path: DocsStructureItemTab.Overview, component: DocsComponentOverviewComponent }, + { path: DocsStructureItemTab.Overview, loadComponent: loadComponentOverview }, { path: '**', redirectTo: DocsStructureItemTab.Overview } ] }, @@ -89,22 +98,22 @@ export const DOCS_ROUTES: Routes = [ }, { path: `${DocsStructureCategoryId.Components}/:id`, - component: DocsComponentViewerComponent, + loadComponent: loadComponentViewer, children: [ { path: '', redirectTo: DocsStructureItemTab.Overview, pathMatch: 'full' }, { path: DocsStructureItemTab.Overview, - component: DocsComponentOverviewComponent, + loadComponent: loadComponentOverview, pathMatch: 'full' }, { path: DocsStructureItemTab.Api, - component: DocsComponentApiComponent, + loadComponent: loadComponentApi, pathMatch: 'full' }, { path: DocsStructureItemTab.Examples, - component: DocsComponentExamplesComponent, + loadComponent: loadComponentExamples, pathMatch: 'full' }, { path: '**', redirectTo: DocsStructureItemTab.Overview } @@ -121,22 +130,22 @@ export const DOCS_ROUTES: Routes = [ }, { path: `${DocsStructureCategoryId.Other}/:id`, - component: DocsComponentViewerComponent, + loadComponent: loadComponentViewer, children: [ { path: '', redirectTo: DocsStructureItemTab.Overview, pathMatch: 'full' }, { path: DocsStructureItemTab.Overview, - component: DocsComponentOverviewComponent, + loadComponent: loadComponentOverview, pathMatch: 'full' }, { path: DocsStructureItemTab.Api, - component: DocsComponentApiComponent, + loadComponent: loadComponentApi, pathMatch: 'full' }, { path: DocsStructureItemTab.Examples, - component: DocsComponentExamplesComponent, + loadComponent: loadComponentExamples, pathMatch: 'full' }, { path: '**', redirectTo: DocsStructureItemTab.Overview } @@ -146,16 +155,32 @@ export const DOCS_ROUTES: Routes = [ /** * Icons section routes */ - { path: DocsStructureCategoryId.Icons, component: DocsIconsViewerComponent } + { + path: DocsStructureCategoryId.Icons, + loadComponent: () => + import('./components/icons-viewer/icons-viewer.component').then((m) => m.DocsIconsViewerComponent) + } ] }, // todo DS-4873 - { path: 'examples/popover', component: DocsPopoverExample }, - { path: 'examples/select', component: DocsSelectExample }, + { + path: 'examples/popover', + loadComponent: () => + import('./components/popover-example/popover-example.component').then((m) => m.DocsPopoverExample) + }, + { + path: 'examples/select', + loadComponent: () => + import('./components/select-example/select-example.component').then((m) => m.DocsSelectExample) + }, /** * Error routes */ - { path: http404Redirect, component: DocsPageNotFoundComponent }, + { + path: http404Redirect, + loadComponent: () => + import('./components/page-not-found/page-not-found.component').then((m) => m.DocsPageNotFoundComponent) + }, { path: '**', redirectTo: http404Redirect } ]; diff --git a/apps/docs/src/app/services/analytics.ts b/apps/docs/src/app/services/analytics.ts new file mode 100644 index 0000000000..40f74c9312 --- /dev/null +++ b/apps/docs/src/app/services/analytics.ts @@ -0,0 +1,76 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + PLATFORM_ID, + provideAppInitializer +} from '@angular/core'; + +const YANDEX_METRIKA_ID = 106476768; +const YANDEX_METRIKA_SRC = 'https://mc.yandex.ru/metrika/tag.js'; + +type YandexMetrikaFn = ((...args: unknown[]) => void) & { a?: unknown[][]; l?: number }; + +declare global { + interface Window { + ym?: YandexMetrikaFn; + } +} + +/** + * Loads the Yandex.Metrika counter from Angular instead of an inline ` +