Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"aot": true,
"assets": [
"src/assets",
"src/static-files",
"src/robots.txt"
],
"styles": [
Expand Down
126 changes: 126 additions & 0 deletions src/app/shared/html-content.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 missing', 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('Not Found', { status: 404, statusText: 'Not Found' });
tick();

const fallbackRequest = httpMock.expectOne('/repository/static-files/license-ud-1.0.html');
fallbackRequest.flush('Fallback content');
tick();

expect(content).toBe('Fallback content');
}));

it('should fallback from locale-specific to default URL when locale returns 404', fakeAsync(() => {
setup('/');
localeService.languageCode = 'cs';

let content: string | undefined;
service.getHmtlContentByPathAndLocale('license').then((result) => {
content = result;
});

httpMock.expectOne('/static-files/cs/license.html')
.flush('Not Found', { status: 404, statusText: 'Not Found' });
tick();

httpMock.expectOne('/static-files/license.html').flush('<div>English Content</div>');
tick();

expect(content).toBe('<div>English Content</div>');
}));

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('');
});
});
93 changes: 81 additions & 12 deletions src/app/shared/html-content.service.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,94 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { isPlatformServer } from '@angular/common';
import { Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core';
import { HttpClient, HttpResponse } 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 { isEmpty } 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.
*/
@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.
* Load `.html` file content and return the full response.
* @param url file location
*/
fetchHtmlContent(url: string) {
// catchError -> return empty value.
return this.http.get(url, { responseType: 'text' }).pipe(
catchError(() => observableOf('')));
const namespacedUrl = this.composeNamespacedUrl(url);
const runtimeUrl = this.buildRuntimeUrl(namespacedUrl);
return this.http.get(runtimeUrl, { responseType: 'text', observe: 'response' }).pipe(
catchError((error) => observableOf(new HttpResponse({ status: error.status || 0, body: '' }))));
}

/**
* Load HTML content for a single URL attempt and handle cached 304 responses.
* @param url file location
*/
private async loadHtmlContent(url: string): Promise<string | undefined> {
const response = await firstValueFrom(this.fetchHtmlContent(url));
if (response.status === 200) {
return response.body ?? '';
}
if (response.status === 304) {
return response.body ?? '';
}
return undefined;
}

/**
Expand All @@ -40,15 +107,17 @@ export class HtmlContentService {
url += isEmpty(language) ? '/' + fileName : '/' + language + '/' + fileName;
// Add `.html` suffix to get the current html file
url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX;
let potentialContent = await firstValueFrom(this.fetchHtmlContent(url));
if (isNotEmpty(potentialContent)) {
let potentialContent = await this.loadHtmlContent(url);
if (potentialContent !== undefined) {
return potentialContent;
}

// If the file wasn't find, get the non-translated file from the default package.
url = STATIC_FILES_PROJECT_PATH + '/' + fileName;
potentialContent = await firstValueFrom(this.fetchHtmlContent(url));
if (isNotEmpty(potentialContent)) {
// Add `.html` suffix to match localized request behavior
url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX;
potentialContent = await this.loadHtmlContent(url);
if (potentialContent !== undefined) {
return potentialContent;
}
}
Expand Down
19 changes: 18 additions & 1 deletion src/app/static-page/static-page.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
<div class="container" >
<div class="container text-center my-5" *ngIf="contentState === 'loading'">
<ds-themed-loading [spinner]="true" [showMessage]="false"></ds-themed-loading>
</div>

<!-- Show static page content when found -->
<div class="container" *ngIf="contentState === 'found'">
<div [innerHTML]="(htmlContent | async) | dsSafeHtml" (click)="processLinks($event)"></div>
</div>

<!-- Show 404 error when content not found (matches PageNotFoundComponent design) -->
<div class="container page-not-found" *ngIf="contentState === 'not-found' && !(htmlContent | async)">
<h1>404</h1>
<h2><small>{{"static-page.404.page-not-found" | translate}}</small></h2>
<br/>
<p>{{"static-page.404.help" | translate}}</p>
<br/>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{"static-page.404.link.home-page" | translate}}</a>
</p>
</div>
Loading