diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 0b3e928f9..c269b82e1 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -137,6 +137,10 @@ export const serverRoutes: ServerRoute[] = [ path: ':id/overview', renderMode: RenderMode.Server, }, + { + path: ':id/metadata/:recordId', + renderMode: RenderMode.Server, + }, { path: ':id/files/**', renderMode: RenderMode.Server, diff --git a/src/app/core/services/osf-config.service.ts b/src/app/core/services/osf-config.service.ts index 8a237ddd9..7034d30b0 100644 --- a/src/app/core/services/osf-config.service.ts +++ b/src/app/core/services/osf-config.service.ts @@ -43,20 +43,24 @@ export class OSFConfigService { * On the server, this is skipped as config is only needed in the browser. */ async load(): Promise { - if (!this.config && isPlatformBrowser(this.platformId)) { + if (this.config) return; + + if (isPlatformBrowser(this.platformId)) { this.config = await lastValueFrom( this.http.get('/assets/config/config.json').pipe( shareReplay(1), catchError(() => of({} as ConfigModel)) ) ); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.config = ((globalThis as any).__SSR_CONFIG__ ?? {}) as ConfigModel; + } - // Apply every key from config to environment - for (const [key, value] of Object.entries(this.config)) { - // eslint-disable-next-line - // @ts-ignore - this.environment[key] = value; - } + for (const [key, value] of Object.entries(this.config)) { + // eslint-disable-next-line + // @ts-ignore + this.environment[key] = value; } } } diff --git a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts index 551a56853..cc50e65dd 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts @@ -41,8 +41,8 @@ describe('FileDetailComponent', () => { } as unknown as jest.Mocked; const mockRoute: Partial = { - params: of({ providerId: 'osf', preprintId: 'p1' }), - queryParams: of({ providerId: 'osf', preprintId: 'p1' }), + params: of({ providerId: 'osf', fileGuid: 'file-1' }), + queryParams: of({ providerId: 'osf', fileGuid: 'file-1' }), }; (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { switch (selector) { @@ -79,6 +79,7 @@ describe('FileDetailComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(FileDetailComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; fixture.detectChanges(); }); @@ -95,4 +96,15 @@ describe('FileDetailComponent', () => { it('should call dataciteService.logIdentifiableView on start ', () => { expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.fileMetadata$); }); + + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/file-1/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/file-1/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); }); diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index 9dc06ff04..1ec9d858f 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -19,6 +19,8 @@ import { effect, HostBinding, inject, + OnDestroy, + OnInit, signal, } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; @@ -47,6 +49,7 @@ import { pathJoin } from '@osf/shared/helpers/path-join.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileDetailsModel } from '@shared/models/files/file.model'; @@ -94,7 +97,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, providers: [DatePipe], }) -export class FileDetailComponent { +export class FileDetailComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; readonly store = inject(Store); @@ -111,6 +114,7 @@ export class FileDetailComponent { private readonly translateService = inject(TranslateService); private readonly environment = inject(ENVIRONMENT); private readonly clipboard = inject(Clipboard); + private readonly signpostingService = inject(SignpostingService); readonly dataciteService = inject(DataciteService); @@ -284,6 +288,16 @@ export class FileDetailComponent { this.dataciteService.logIdentifiableView(this.fileMetadata$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } + ngOnInit(): void { + this.signpostingService.addSignposting(this.fileGuid); + } + + ngOnDestroy(): void { + if (this.fileGuid) { + this.signpostingService.removeSignpostingLinkTags(); + } + } + getIframeLink(version: string) { const url = this.getMfrUrlWithVersion(version); if (url) { diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index e20e097f7..4914f6991 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -11,6 +11,7 @@ import { DestroyRef, effect, inject, + OnDestroy, OnInit, signal, } from '@angular/core'; @@ -24,6 +25,7 @@ import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; import { @@ -117,7 +119,7 @@ import { styleUrl: './metadata.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataComponent implements OnInit { +export class MetadataComponent implements OnInit, OnDestroy { private readonly activeRoute = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -125,6 +127,7 @@ export class MetadataComponent implements OnInit { private readonly toastService = inject(ToastService); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly environment = inject(ENVIRONMENT); + private readonly signpostingService = inject(SignpostingService); private resourceId = ''; @@ -264,12 +267,18 @@ export class MetadataComponent implements OnInit { this.actions.getCedarTemplates(); this.actions.fetchSelectedSubjects(this.resourceId, this.resourceType()); + this.signpostingService.addMetadataSignposting(this.resourceId); + if (this.isProjectType()) { this.actions.getProjectSubmissions(this.resourceId); } } } + ngOnDestroy(): void { + this.signpostingService.removeSignpostingLinkTags(); + } + onTabChange(tabId: string | number): void { const tab = this.tabs().find((x) => x.id === tabId.toString()); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 1fbcfc53f..67bae534f 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -466,6 +466,7 @@ describe('PreprintDetailsComponent SSR Tests', () => { store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintDetailsComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; }); it('should render PreprintDetailsComponent server-side without errors', () => { @@ -475,6 +476,17 @@ describe('PreprintDetailsComponent SSR Tests', () => { expect(component).toBeTruthy(); }); + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/preprint-1/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/preprint-1/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); + it('should not access browser-only APIs during SSR', () => { const platformId = TestBed.inject(PLATFORM_ID); expect(platformId).toBe('server'); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index 0f02c16dc..63cc1772f 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -35,6 +35,7 @@ import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; @@ -104,6 +105,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private readonly prerenderReady = inject(PrerenderReadyService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly signpostingService = inject(SignpostingService); private readonly environment = inject(ENVIRONMENT); @@ -304,6 +306,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.actions.getPreprintProviderById(this.providerId()); this.fetchPreprint(this.preprintId()); + this.signpostingService.addSignposting(this.preprintId()); + this.dataciteService.logIdentifiableView(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } @@ -313,6 +317,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.actions.clearCurrentProvider(); } + this.signpostingService.removeSignpostingLinkTags(); + this.helpScoutService.unsetResourceType(); } diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index 61373020d..ae3182118 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -262,6 +262,7 @@ describe('ProjectOverviewComponent SSR Tests', () => { store = TestBed.inject(Store); fixture = TestBed.createComponent(ProjectOverviewComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; }); it('should render ProjectOverviewComponent server-side without errors', () => { @@ -285,6 +286,17 @@ describe('ProjectOverviewComponent SSR Tests', () => { expect(component).toBeTruthy(); }); + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/project-123/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/project-123/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); + it('should not call browser-only actions in ngOnDestroy during SSR', () => { const dispatchSpy = jest.spyOn(store, 'dispatch'); diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 209c33834..cc22bd778 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -16,6 +16,7 @@ import { effect, HostBinding, inject, + OnDestroy, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -35,6 +36,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { Mode } from '@osf/shared/enums/mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { @@ -93,7 +95,7 @@ import { ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProjectOverviewComponent implements OnInit { +export class ProjectOverviewComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex flex-1 flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); @@ -104,6 +106,7 @@ export class ProjectOverviewComponent implements OnInit { private readonly customDialogService = inject(CustomDialogService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly signpostingService = inject(SignpostingService); submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); collectionProvider = select(CollectionsSelectors.getCollectionProvider); @@ -193,9 +196,14 @@ export class ProjectOverviewComponent implements OnInit { this.actions.getBookmarksId(); this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); + this.signpostingService.addSignposting(projectId); } } + ngOnDestroy(): void { + this.signpostingService.removeSignpostingLinkTags(); + } + handleOpenMakeDecisionDialog() { this.customDialogService .open(MakeDecisionDialogComponent, { diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index ed58b1a97..a0633cd37 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -629,6 +629,7 @@ describe('RegistryOverviewComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(RegistryOverviewComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; }); it('should render server-side without errors', () => { @@ -638,6 +639,16 @@ describe('RegistryOverviewComponent', () => { expect(component).toBeTruthy(); }); + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/registry-1/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/registry-1/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); + it('should not access browser-only APIs during SSR', () => { const platformId = TestBed.inject(PLATFORM_ID); expect(platformId).toBe('server'); diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 69466b6e5..d8331afbf 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -15,6 +15,8 @@ import { effect, HostBinding, inject, + OnDestroy, + OnInit, signal, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @@ -32,6 +34,7 @@ import { toCamelCase } from '@osf/shared/helpers/camel-case'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; @@ -75,7 +78,7 @@ import { styleUrl: './registry-overview.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistryOverviewComponent { +export class RegistryOverviewComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -84,6 +87,7 @@ export class RegistryOverviewComponent { private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly customDialogService = inject(CustomDialogService); private readonly loaderService = inject(LoaderService); + private readonly signpostingService = inject(SignpostingService); readonly registry = select(RegistrySelectors.getRegistry); readonly isRegistryLoading = select(RegistrySelectors.isRegistryLoading); @@ -169,6 +173,14 @@ export class RegistryOverviewComponent { .subscribe(); } + ngOnInit(): void { + this.signpostingService.addSignposting(this.registryId()); + } + + ngOnDestroy(): void { + this.signpostingService.removeSignpostingLinkTags(); + } + openRevision(revisionIndex: number): void { this.selectedRevisionIndex.set(revisionIndex); } diff --git a/src/app/shared/models/signposting.model.ts b/src/app/shared/models/signposting.model.ts new file mode 100644 index 000000000..91f08817b --- /dev/null +++ b/src/app/shared/models/signposting.model.ts @@ -0,0 +1,8 @@ +export const LINKSET_TYPE = 'application/linkset'; +export const LINKSET_JSON_TYPE = 'application/linkset+json'; + +export interface SignpostingLink { + rel: string; + href: string; + type: string; +} diff --git a/src/app/shared/services/signposting.service.spec.ts b/src/app/shared/services/signposting.service.spec.ts new file mode 100644 index 000000000..e1f160546 --- /dev/null +++ b/src/app/shared/services/signposting.service.spec.ts @@ -0,0 +1,68 @@ +import { RendererFactory2, RESPONSE_INIT } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { SignpostingService } from './signposting.service'; + +describe('Service: Signposting', () => { + let service: SignpostingService; + let mockResponseInit: ResponseInit; + let createdLinks: Record[]; + let mockAppendChild: jest.Mock; + + beforeEach(() => { + createdLinks = []; + mockAppendChild = jest.fn(); + mockResponseInit = { headers: new Headers() }; + + TestBed.configureTestingModule({ + providers: [ + SignpostingService, + { provide: RESPONSE_INIT, useValue: mockResponseInit }, + { + provide: RendererFactory2, + useValue: { + createRenderer: () => ({ + createElement: jest.fn().mockImplementation(() => { + const link: Record = {}; + createdLinks.push(link); + return link; + }), + setAttribute: jest.fn().mockImplementation((el, attr, value) => { + el[attr] = value; + }), + appendChild: mockAppendChild, + }), + }, + }, + ], + }); + + service = TestBed.inject(SignpostingService); + }); + + it('should set headers using addSignposting', () => { + service.addSignposting('abcde'); + const linkHeader = (mockResponseInit.headers as Headers).get('Link'); + expect(linkHeader).toBe( + '; rel="linkset"; type="application/linkset", ; rel="linkset"; type="application/linkset+json"' + ); + }); + + it('should add link tags using addSignposting', () => { + service.addSignposting('abcde'); + + expect(createdLinks).toEqual([ + { + rel: 'linkset', + href: 'https://staging3.osf.io/metadata/abcde/?format=linkset', + type: 'application/linkset', + }, + { + rel: 'linkset', + href: 'https://staging3.osf.io/metadata/abcde/?format=linkset-json', + type: 'application/linkset+json', + }, + ]); + expect(mockAppendChild).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/shared/services/signposting.service.ts b/src/app/shared/services/signposting.service.ts new file mode 100644 index 000000000..43765f617 --- /dev/null +++ b/src/app/shared/services/signposting.service.ts @@ -0,0 +1,91 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, RendererFactory2, RESPONSE_INIT } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { LINKSET_JSON_TYPE, LINKSET_TYPE, SignpostingLink } from '../models/signposting.model'; + +@Injectable({ + providedIn: 'root', +}) +export class SignpostingService { + private readonly document = inject(DOCUMENT); + private readonly environment = inject(ENVIRONMENT); + private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); + private readonly renderer = inject(RendererFactory2).createRenderer(null, null); + + addSignposting(guid: string): void { + const links = this.generateSignpostingLinks(guid); + + this.addSignpostingLinkHeaders(links); + this.addSignpostingLinkTags(links); + } + + addMetadataSignposting(guid: string): void { + const links = this.generateSignpostingLinks(guid, true); + + this.addSignpostingLinkHeaders(links); + this.addSignpostingLinkTags(links); + } + + removeSignpostingLinkTags(): void { + const linkElements = this.document.head.querySelectorAll('link[rel="linkset"], link[rel="describes"]'); + linkElements.forEach((linkElement) => { + this.renderer.removeChild(this.document.head, linkElement); + }); + } + + private generateSignpostingLinks(guid: string, isMetadata?: boolean): SignpostingLink[] { + if (isMetadata) { + return [ + { + rel: 'describes', + href: `${this.environment.webUrl}/${guid}/`, + type: 'text/html', + }, + ]; + } + const baseUrl = `${this.environment.webUrl}/metadata/${guid}/`; + + return [ + { + rel: 'linkset', + href: this.buildUrl(baseUrl, 'linkset'), + type: LINKSET_TYPE, + }, + { + rel: 'linkset', + href: this.buildUrl(baseUrl, 'linkset-json'), + type: LINKSET_JSON_TYPE, + }, + ]; + } + + private buildUrl(base: string, format: string): string { + const url = new URL(base); + url.searchParams.set('format', format); + return url.toString(); + } + + private addSignpostingLinkHeaders(links: SignpostingLink[]): void { + if (!this.responseInit) return; + + const headers = + this.responseInit.headers instanceof Headers ? this.responseInit.headers : new Headers(this.responseInit.headers); + + const linkHeaderValue = links.map((link) => `<${link.href}>; rel="${link.rel}"; type="${link.type}"`).join(', '); + + headers.set('Link', linkHeaderValue); + this.responseInit.headers = headers; + } + + private addSignpostingLinkTags(links: SignpostingLink[]): void { + links.forEach((link) => { + const linkElement = this.renderer.createElement('link'); + this.renderer.setAttribute(linkElement, 'rel', link.rel); + this.renderer.setAttribute(linkElement, 'href', link.href); + this.renderer.setAttribute(linkElement, 'type', link.type); + this.renderer.appendChild(this.document.head, linkElement); + }); + } +} diff --git a/src/server.ts b/src/server.ts index 1a7ffc292..272569de9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,25 @@ import { } from '@angular/ssr/node'; import express from 'express'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); +const configPath = resolve(browserDistFolder, 'assets/config/config.json'); + +if (existsSync(configPath)) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__SSR_CONFIG__ = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + // eslint-disable-next-line no-console + console.warn('Failed to parse SSR config at', configPath); + } +} + const app = express(); const angularApp = new AngularNodeAppEngine();