From 9b354d09aff2bb40df3e20c8a67705863d1863bc Mon Sep 17 00:00:00 2001 From: Amad Ul Hassan Date: Tue, 10 Mar 2026 15:02:37 +0100 Subject: [PATCH] Add src/static-files to build assets (ufal/dspace-angular#103) * Add src/static-files to build assets Update angular.json to include "src/static-files" in the build assets array so files placed there are copied into the build output. This ensures additional static resources are available at runtime without changing code paths. * Support namespaced/SSR HTML loading; add tests Update HtmlContentService to compose namespaced URLs, build runtime absolute URLs when running on the server (REQUEST/PLATFORM_ID), and centralize HTTP text fetching via getHtmlContent. Ensure APP_CONFIG ui.nameSpace is respected and handle absolute URLs and existing namespace prefixes. Add comprehensive unit tests (html-content.service.spec.ts) covering namespaced/default paths, locale fallback, and error handling. Adjust static-page.component.spec.ts to use a RouterMock with configurable route and add a test asserting the service is called with the route's HTML filename. (cherry picked from commit 4eadd8b5d3d43e4c6a79644fa3822d95f5771d8d) --- angular.json | 1 + src/app/shared/html-content.service.spec.ts | 107 ++++++++++++++++++ src/app/shared/html-content.service.ts | 59 +++++++++- .../static-page/static-page.component.spec.ts | 14 ++- 4 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/app/shared/html-content.service.spec.ts diff --git a/angular.json b/angular.json index a57b7582109..570c438844c 100644 --- a/angular.json +++ b/angular.json @@ -40,6 +40,7 @@ "aot": true, "assets": [ "src/assets", + "src/static-files", "src/robots.txt" ], "styles": [ diff --git a/src/app/shared/html-content.service.spec.ts b/src/app/shared/html-content.service.spec.ts new file mode 100644 index 00000000000..683b177ab08 --- /dev/null +++ b/src/app/shared/html-content.service.spec.ts @@ -0,0 +1,107 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { firstValueFrom } from 'rxjs'; + +import { HtmlContentService } from './html-content.service'; +import { LocaleService } from '../core/locale/locale.service'; +import { APP_CONFIG } from '../../config/app-config.interface'; + +class LocaleServiceStub { + languageCode = 'en'; + + getCurrentLanguageCode(): string { + return this.languageCode; + } +} + +describe('HtmlContentService', () => { + let service: HtmlContentService; + let httpMock: HttpTestingController; + let localeService: LocaleServiceStub; + + function setup(nameSpace: string): void { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + HtmlContentService, + { provide: LocaleService, useClass: LocaleServiceStub }, + { + provide: APP_CONFIG, + useValue: { + ui: { nameSpace }, + }, + }, + ], + }); + + service = TestBed.inject(HtmlContentService); + httpMock = TestBed.inject(HttpTestingController); + localeService = TestBed.inject(LocaleService) as any; + } + + afterEach(() => { + if (httpMock) { + httpMock.verify(); + } + }); + + it('should request root namespaced URL for default locale', async () => { + setup('/'); + localeService.languageCode = 'en'; + + const promise = service.getHmtlContentByPathAndLocale('license-ud-1.0'); + + const request = httpMock.expectOne('/static-files/license-ud-1.0.html'); + expect(request.request.method).toBe('GET'); + request.flush('Universal Dependencies 1.0 License Set'); + + const content = await promise; + expect(content).toBe('Universal Dependencies 1.0 License Set'); + }); + + it('should request locale-specific namespaced URL for non-default locale', async () => { + setup('/repository'); + localeService.languageCode = 'cs'; + + const promise = service.getHmtlContentByPathAndLocale('license-ud-1.0'); + + const request = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html'); + expect(request.request.method).toBe('GET'); + request.flush('Localized content'); + + const content = await promise; + expect(content).toBe('Localized content'); + }); + + it('should fallback from locale-specific to default namespaced URL when localized content is empty', fakeAsync(() => { + setup('/repository/'); + localeService.languageCode = 'cs'; + + let content: string | undefined; + service.getHmtlContentByPathAndLocale('license-ud-1.0').then((result) => { + content = result; + }); + + const localizedRequest = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html'); + localizedRequest.flush(''); + tick(); + + const fallbackRequest = httpMock.expectOne('/repository/static-files/license-ud-1.0'); + fallbackRequest.flush('Fallback content'); + tick(); + + expect(content).toBe('Fallback content'); + })); + + it('should return empty string from getHtmlContent when request fails', async () => { + setup('/repository'); + + const contentPromise = firstValueFrom(service.getHtmlContent('static-files/missing-page.html')); + + const request = httpMock.expectOne('/repository/static-files/missing-page.html'); + request.flush('Not Found', { status: 404, statusText: 'Not Found' }); + + const content = await contentPromise; + expect(content).toBe(''); + }); +}); diff --git a/src/app/shared/html-content.service.ts b/src/app/shared/html-content.service.ts index 2b31c7cfb81..7723ac94da9 100644 --- a/src/app/shared/html-content.service.ts +++ b/src/app/shared/html-content.service.ts @@ -1,10 +1,13 @@ -import { Injectable } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { catchError } from 'rxjs/operators'; import { firstValueFrom, of as observableOf } from 'rxjs'; import { HTML_SUFFIX, STATIC_FILES_PROJECT_PATH } from '../static-page/static-page-routing-paths'; import { isEmpty, isNotEmpty } from './empty.util'; import { LocaleService } from '../core/locale/locale.service'; +import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; +import { REQUEST } from '@nguniversal/express-engine/tokens'; /** * Service for loading static `.html` files stored in the `/static-files` folder. @@ -12,16 +15,62 @@ import { LocaleService } from '../core/locale/locale.service'; @Injectable() export class HtmlContentService { constructor(private http: HttpClient, - private localeService: LocaleService,) {} + private localeService: LocaleService, + @Inject(APP_CONFIG) protected appConfig?: AppConfig, + @Inject(PLATFORM_ID) private platformId?: object, + @Optional() @Inject(REQUEST) private request?: any, + ) {} + + private getNamespacePrefix(): string { + const nameSpace = this.appConfig?.ui?.nameSpace ?? '/'; + if (nameSpace === '/') { + return ''; + } + return nameSpace.endsWith('/') ? nameSpace.slice(0, -1) : nameSpace; + } + + private composeNamespacedUrl(url: string): string { + if (/^https?:\/\//i.test(url)) { + return url; + } + + const normalizedPath = url.startsWith('/') ? url : `/${url}`; + const namespacePrefix = this.getNamespacePrefix(); + + if (namespacePrefix && normalizedPath.startsWith(`${namespacePrefix}/`)) { + return normalizedPath; + } + + return `${namespacePrefix}${normalizedPath}`; + } + + private buildRuntimeUrl(path: string): string { + if (!isPlatformServer(this.platformId) || !this.request) { + return path; + } + + const protocol = this.request.protocol; + const host = this.request.get?.('host'); + if (!protocol || !host) { + return path; + } + + return `${protocol}://${host}${path}`; + } + + getHtmlContent(url: string) { + const namespacedUrl = this.composeNamespacedUrl(url); + const runtimeUrl = this.buildRuntimeUrl(namespacedUrl); + return this.http.get(runtimeUrl, { responseType: 'text' }).pipe( + catchError(() => observableOf(''))); + } /** * Load `.html` file content or return empty string if an error. * @param url file location */ fetchHtmlContent(url: string) { - // catchError -> return empty value. - return this.http.get(url, { responseType: 'text' }).pipe( - catchError(() => observableOf(''))); + return this.getHtmlContent(url); } /** diff --git a/src/app/static-page/static-page.component.spec.ts b/src/app/static-page/static-page.component.spec.ts index 69d65248509..b1017e8164c 100644 --- a/src/app/static-page/static-page.component.spec.ts +++ b/src/app/static-page/static-page.component.spec.ts @@ -11,12 +11,15 @@ import { environment } from '../../environments/environment'; import { ClarinSafeHtmlPipe } from '../shared/utils/clarin-safehtml.pipe'; describe('StaticPageComponent', () => { - async function setupTest(html: string, restBase?: string) { + async function setupTest(html: string, restBase?: string, route: string = '/static/test-file.html') { const htmlContentService = jasmine.createSpyObj('htmlContentService', { fetchHtmlContent: of(html), getHmtlContentByPathAndLocale: Promise.resolve(html) }); + const router = new RouterMock(); + router.setRoute(route); + const appConfig = { ...environment, ui: { @@ -36,7 +39,7 @@ describe('StaticPageComponent', () => { ], providers: [ { provide: HtmlContentService, useValue: htmlContentService }, - { provide: Router, useValue: new RouterMock() }, + { provide: Router, useValue: router }, { provide: APP_CONFIG, useValue: appConfig } ] }).compileComponents(); @@ -58,6 +61,13 @@ describe('StaticPageComponent', () => { expect(component.htmlContent.value).toBe('
TEST MESSAGE
'); }); + it('should call HtmlContentService with the route html file name', async () => { + const { component, htmlContentService } = await setupTest('
TEST MESSAGE
', undefined, '/static/license-ud-1.0.html'); + await component.ngOnInit(); + + expect(htmlContentService.getHmtlContentByPathAndLocale).toHaveBeenCalledWith('license-ud-1.0.html'); + }); + it('should rewrite OAI link with rest.baseUrl', async () => { const oaiHtml = 'OAI'; const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/server');