diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 924a5eaf2e3..a7bc970c595 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -43,6 +43,12 @@ jobs:
- name: Checkout codebase
uses: actions/checkout@v3
+ # Generate deployed-version info (git hash, commit date, build run) into a static
+ # file that the UI serves at `/static/VERSION_D` (issue #813). Must run before the
+ # Docker build so the file is included in the build context.
+ - name: Add version
+ run: python scripts/sourceversion.py ${{ github.server_url }}/${{ github.repository }}/actions/runs/ ${{ github.run_id }} > src/static-files/VERSION_D.html
+
# https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v2
diff --git a/angular.json b/angular.json
index 386644ac725..9adf078e539 100644
--- a/angular.json
+++ b/angular.json
@@ -41,6 +41,11 @@
"aot": true,
"assets": [
"src/assets",
+ {
+ "glob": "VERSION_D.html",
+ "input": "src/static-files",
+ "output": "static-files"
+ },
"src/robots.txt"
],
"styles": [
diff --git a/scripts/sourceversion.py b/scripts/sourceversion.py
new file mode 100644
index 00000000000..c6d50504613
--- /dev/null
+++ b/scripts/sourceversion.py
@@ -0,0 +1,38 @@
+import subprocess
+import sys
+from datetime import datetime, timezone
+
+# when next editing this script, please introduce argparse.
+# do not forget, it is called in BE by .github\workflows\reusable-docker-build.yml
+# argparse must be introduced there.
+# that action also calls BE version of this script, which is different (BE: scripts/sourceversion.py).
+# It must also cooperate with argparse
+
+# the idea is, that this will be different on each branch, but could be possibly passed by argv/argparse
+RELEASE_TAG_BASE='none'
+
+def get_time_in_timezone(zone: str = "Europe/Bratislava"):
+ try:
+ from zoneinfo import ZoneInfo
+ my_tz = ZoneInfo(zone)
+ except Exception as e:
+ my_tz = timezone.utc
+ return datetime.now(my_tz)
+
+
+if __name__ == '__main__':
+ ts = get_time_in_timezone()
+ # we have html tags, since this script ends up creating VERSION_D.html
+ print(f"
This info was generated on:
{ts.strftime('%Y-%m-%d %H:%M:%S %Z%z')}
")
+
+ cmd = 'git log -1 --pretty=format:"Git hash:
%H
Date of commit:
%ai
"'
+ subprocess.check_call(cmd, shell=True)
+
+ # when adding argparse, this should be a bit more obvious
+ link = sys.argv[1] + sys.argv[2]
+ print('
Build run:
' + link + ' ')
+
+ link = "https://github.com/dataquest-dev/dspace-angular/releases/tag/" \
+ + RELEASE_TAG_BASE + "-" + datetime.now().strftime('%Y.%m.') + sys.argv[2]
+
+ print('
Release link:
' + link + ' (if it does not work, then this is not an official release instance) ')
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index 34c6bc0efcd..d80b6374431 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -43,6 +43,7 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard';
import { MenuResolver } from './menu.resolver';
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-paths';
+import { STATIC_PAGE_PATH } from './static-page/static-page-routing-paths';
@NgModule({
imports: [
@@ -260,6 +261,10 @@ import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-path
loadChildren: () => import('./handle-page/handle-page.module').then((m) => m.HandlePageModule),
canActivate: [SiteAdministratorGuard],
},
+ {
+ path: STATIC_PAGE_PATH,
+ loadChildren: () => import('./static-page/static-page.module').then((m) => m.StaticPageModule),
+ },
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }
]
}
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..dbc04dcf4e4
--- /dev/null
+++ b/src/app/shared/html-content.service.spec.ts
@@ -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('English Content
');
+ tick();
+
+ expect(content).toBe('English 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
new file mode 100644
index 00000000000..664eaf71e22
--- /dev/null
+++ b/src/app/shared/html-content.service.ts
@@ -0,0 +1,124 @@
+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 } 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,
+ @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 and return the full response.
+ * @param url file location
+ */
+ fetchHtmlContent(url: string) {
+ 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 {
+ const response = await firstValueFrom(this.fetchHtmlContent(url));
+ if (response.status === 200) {
+ return response.body ?? '';
+ }
+ if (response.status === 304) {
+ return response.body ?? '';
+ }
+ return undefined;
+ }
+
+ /**
+ * Get the html file content as a string by the file name and the current locale.
+ */
+ async getHmtlContentByPathAndLocale(fileName: string) {
+ let url = '';
+ // Get current language
+ let language = this.localeService.getCurrentLanguageCode();
+ // If language is default = `en` do not load static files from translated package e.g. `cs`.
+ language = language === 'en' ? '' : language;
+
+ // Try to find the html file in the translated package. `static-files/language_code/some_file.html`
+ // Compose url
+ url = STATIC_FILES_PROJECT_PATH;
+ 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 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;
+ // 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;
+ }
+ }
+}
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index cecf0cec284..10d40d47da3 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -250,6 +250,7 @@ import {
ItemPageTitleFieldComponent
} from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component';
import { MarkdownPipe } from './utils/markdown.pipe';
+import { ClarinSafeHtmlPipe } from './utils/clarin-safehtml.pipe';
import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module';
import { MenuModule } from './menu/menu.module';
import {
@@ -318,7 +319,8 @@ const PIPES = [
ClarinLicenseCheckedPipe,
ClarinLicenseLabelRadioValuePipe,
ClarinLicenseRequiredInfoPipe,
- CharToEndPipe
+ CharToEndPipe,
+ ClarinSafeHtmlPipe
];
const COMPONENTS = [
diff --git a/src/app/shared/utils/clarin-safehtml.pipe.ts b/src/app/shared/utils/clarin-safehtml.pipe.ts
new file mode 100644
index 00000000000..ebf2e21c8da
--- /dev/null
+++ b/src/app/shared/utils/clarin-safehtml.pipe.ts
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+
+/**
+ * Pipe to keep html tags e.g., `id` in the `innerHTML` attribute.
+ */
+@Pipe({
+ name: 'dsSafeHtml'
+})
+export class ClarinSafeHtmlPipe implements PipeTransform {
+ constructor(private sanitized: DomSanitizer) {}
+ transform(htmlString: string): SafeHtml {
+ return this.sanitized.bypassSecurityTrustHtml(htmlString);
+ }
+}
diff --git a/src/app/static-page/static-page-routing-paths.ts b/src/app/static-page/static-page-routing-paths.ts
new file mode 100644
index 00000000000..e6fd63cf4d2
--- /dev/null
+++ b/src/app/static-page/static-page-routing-paths.ts
@@ -0,0 +1,7 @@
+/**
+ * Constants for `/static` route.
+ */
+export const STATIC_PAGE_PATH = 'static';
+export const STATIC_FILES_PROJECT_PATH = 'static-files';
+export const HTML_SUFFIX = '.html';
+export const STATIC_FILES_DEFAULT_ERROR_PAGE_PATH = STATIC_FILES_PROJECT_PATH + '/' + 'error.html';
diff --git a/src/app/static-page/static-page-routing.module.ts b/src/app/static-page/static-page-routing.module.ts
new file mode 100644
index 00000000000..93990bfa078
--- /dev/null
+++ b/src/app/static-page/static-page-routing.module.ts
@@ -0,0 +1,19 @@
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { StaticPageComponent } from './static-page.component';
+
+const routes: Routes = [
+ {
+ path: '',
+ children: [
+ { path: '', component: StaticPageComponent },
+ { path: ':htmlFileName', component: StaticPageComponent },
+ ],
+ },
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class StaticPageRoutingModule { }
diff --git a/src/app/static-page/static-page.component.html b/src/app/static-page/static-page.component.html
new file mode 100644
index 00000000000..372c6496f41
--- /dev/null
+++ b/src/app/static-page/static-page.component.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
404
+
{{"static-page.404.page-not-found" | translate}}
+
+
{{"static-page.404.help" | translate}}
+
+
+ {{"static-page.404.link.home-page" | translate}}
+
+
diff --git a/src/app/static-page/static-page.component.scss b/src/app/static-page/static-page.component.scss
new file mode 100644
index 00000000000..fe7baec6e4c
--- /dev/null
+++ b/src/app/static-page/static-page.component.scss
@@ -0,0 +1,3 @@
+/**
+File for styling the `static-page` component.
+ */
diff --git a/src/app/static-page/static-page.component.spec.ts b/src/app/static-page/static-page.component.spec.ts
new file mode 100644
index 00000000000..f62bf4a1756
--- /dev/null
+++ b/src/app/static-page/static-page.component.spec.ts
@@ -0,0 +1,295 @@
+import { TestBed } from '@angular/core/testing';
+
+import { StaticPageComponent } from './static-page.component';
+import { HtmlContentService } from '../shared/html-content.service';
+import { Router } from '@angular/router';
+import { RouterMock } from '../shared/mocks/router.mock';
+import { TranslateModule } from '@ngx-translate/core';
+import { of } from 'rxjs';
+import { APP_CONFIG } from '../../config/app-config.interface';
+import { environment } from '../../environments/environment';
+import { ClarinSafeHtmlPipe } from '../shared/utils/clarin-safehtml.pipe';
+import { ServerResponseService } from '../core/services/server-response.service';
+
+describe('StaticPageComponent', () => {
+ function createDeferred() {
+ let resolve: (value: T) => void;
+ let reject: (reason?: any) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve: resolve!, reject: reject! };
+ }
+
+ async function setupTest(
+ html: string | undefined,
+ restBase?: string,
+ contentPromise?: Promise,
+ route: string = '/static/test-file.html'
+ ) {
+ const htmlContentService = jasmine.createSpyObj('htmlContentService', {
+ fetchHtmlContent: of(html),
+ getHmtlContentByPathAndLocale: contentPromise ?? Promise.resolve(html)
+ });
+
+ const responseService = jasmine.createSpyObj('responseService', {
+ setNotFound: null
+ });
+
+ const router = new RouterMock();
+ router.setRoute(route);
+
+ const appConfig = {
+ ...environment,
+ ui: {
+ ...(environment as any).ui,
+ nameSpace: '/testNamespace'
+ },
+ rest: {
+ ...(environment as any).rest,
+ baseUrl: restBase
+ }
+ };
+
+ await TestBed.configureTestingModule({
+ declarations: [ StaticPageComponent, ClarinSafeHtmlPipe ],
+ imports: [
+ TranslateModule.forRoot()
+ ],
+ providers: [
+ { provide: HtmlContentService, useValue: htmlContentService },
+ { provide: Router, useValue: router },
+ { provide: ServerResponseService, useValue: responseService },
+ { provide: APP_CONFIG, useValue: appConfig }
+ ]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(StaticPageComponent);
+ const component = fixture.componentInstance;
+ return { fixture, component, htmlContentService, responseService };
+ }
+
+ function createLinkEvent(href: string, useNestedTarget = false): Event {
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', href);
+
+ let target: EventTarget = anchor;
+ if (useNestedTarget) {
+ const nestedElement = document.createElement('span');
+ anchor.appendChild(nestedElement);
+ target = nestedElement;
+ }
+
+ return {
+ target,
+ preventDefault: jasmine.createSpy('preventDefault')
+ } as unknown as Event;
+ }
+
+ it('should create', async () => {
+ const { component } = await setupTest('test
');
+ expect(component).toBeTruthy();
+ });
+
+ it('should load html file content', async () => {
+ const { component } = await setupTest('TEST MESSAGE
');
+ await component.ngOnInit();
+ 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, 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');
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const rewritten = 'https://api.example.org/server/oai/request?verb=ListSets';
+ expect(component.htmlContent.value).toContain(rewritten);
+ const anchor = fixture.nativeElement.querySelector('a');
+ expect(anchor.getAttribute('href')).toBe(rewritten);
+ });
+
+ it('should leave OAI link unchanged when rest.baseUrl is missing', async () => {
+ const oaiHtml = 'OAI';
+ const { fixture, component } = await setupTest(oaiHtml, undefined);
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.htmlContent.value).toContain('/server/oai/request?verb=Identify');
+ });
+
+ it('should avoid double slashes when rest.baseUrl ends with slash', async () => {
+ const oaiHtml = 'OAI';
+ const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/server/');
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.htmlContent.value).toContain('https://api.example.org/server/oai/request?verb=ListRecords');
+ expect(component.htmlContent.value).not.toContain('//oai');
+ });
+
+ it('should include namespace in OAI link when rest.baseUrl has namespace prefix', async () => {
+ const oaiHtml = 'full list';
+ const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/repository/server');
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const rewritten = 'https://api.example.org/repository/server/oai/request?verb=ListMetadataFormats';
+ expect(component.htmlContent.value).toContain(rewritten);
+ const anchor = fixture.nativeElement.querySelector('a');
+ expect(anchor.getAttribute('href')).toBe(rewritten);
+ });
+
+ it('should leave content unchanged when no OAI link is present', async () => {
+ const otherHtml = 'Other';
+ const { fixture, component } = await setupTest(otherHtml, 'https://api.example.org/server');
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.htmlContent.value).toBe(otherHtml);
+ });
+
+ describe('contentState behavior', () => {
+ it('should initialize contentState to "loading"', async () => {
+ const { component } = await setupTest('test
');
+ expect(component.contentState).toBe('loading');
+ });
+
+ it('should set contentState to "found" when content loads successfully', async () => {
+ const { component } = await setupTest('Test Content
');
+ await component.ngOnInit();
+ expect(component.contentState).toBe('found');
+ });
+
+ it('should set contentState to "not-found" when content is undefined', async () => {
+ const { component, responseService } = await setupTest(undefined);
+
+ await component.ngOnInit();
+
+ expect(component.contentState).toBe('not-found');
+ expect(responseService.setNotFound).toHaveBeenCalled();
+ });
+
+ it('should keep loading state and not render 404 before content promise resolves', async () => {
+ const deferred = createDeferred();
+ const { fixture, component } = await setupTest(undefined, undefined, deferred.promise);
+
+ const initPromise = component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.contentState).toBe('loading');
+ expect(component.htmlContent.value).toBe('');
+ expect(fixture.nativeElement.querySelector('.page-not-found')).toBeNull();
+
+ deferred.resolve('Loaded later
');
+ await initPromise;
+ fixture.detectChanges();
+
+ expect(component.contentState).toBe('found');
+ expect(fixture.nativeElement.querySelector('.page-not-found')).toBeNull();
+ });
+
+ it('should reset stale not-found state to loading on init', async () => {
+ const deferred = createDeferred();
+ const { component } = await setupTest(undefined, undefined, deferred.promise);
+
+ component.contentState = 'not-found';
+ component.htmlContent.next('stale
');
+
+ const initPromise = component.ngOnInit();
+
+ expect(component.contentState).toBe('loading');
+ expect(component.htmlContent.value).toBe('');
+
+ deferred.resolve('fresh
');
+ await initPromise;
+
+ expect(component.contentState).toBe('found');
+ });
+ });
+
+ describe('change detection', () => {
+ it('should call changeDetector.detectChanges() after successful content load', async () => {
+ const { component } = await setupTest('test
');
+ spyOn((component as any).changeDetector, 'detectChanges');
+
+ await component.ngOnInit();
+
+ expect((component as any).changeDetector.detectChanges).toHaveBeenCalled();
+ });
+
+ it('should call changeDetector.detectChanges() when content not found', async () => {
+ const { component } = await setupTest(undefined);
+
+ spyOn((component as any).changeDetector, 'detectChanges');
+
+ await component.ngOnInit();
+
+ expect((component as any).changeDetector.detectChanges).toHaveBeenCalled();
+ });
+ });
+
+ describe('link handling', () => {
+ it('should intercept and navigate dot-relative links under the static route', async () => {
+ const { component } = await setupTest('test
');
+ const navigateTo = spyOn(component, 'navigateTo');
+ const event = createLinkEvent('./cite');
+
+ component.processLinks(event);
+
+ expect((event.preventDefault as jasmine.Spy)).toHaveBeenCalled();
+ expect(navigateTo).toHaveBeenCalledWith(`${window.location.origin}/testNamespace/static/cite`);
+ });
+
+ it('should resolve nested relative-link clicks inside anchors', async () => {
+ const { component } = await setupTest('test
');
+ const navigateTo = spyOn(component, 'navigateTo');
+ const event = createLinkEvent('../discover?query=test', true);
+
+ component.processLinks(event);
+
+ expect((event.preventDefault as jasmine.Spy)).toHaveBeenCalled();
+ expect(navigateTo).toHaveBeenCalledWith(`${window.location.origin}/testNamespace/discover?query=test`);
+ });
+
+ it('should not intercept explicit app-route links', async () => {
+ const { component } = await setupTest('test
');
+ const navigateTo = spyOn(component, 'navigateTo');
+ const event = createLinkEvent('contract');
+
+ component.processLinks(event);
+
+ expect((event.preventDefault as jasmine.Spy)).not.toHaveBeenCalled();
+ expect(navigateTo).not.toHaveBeenCalled();
+ });
+
+ it('should not intercept fragment links', async () => {
+ const { component } = await setupTest('test
');
+ const navigateTo = spyOn(component, 'navigateTo');
+ const event = createLinkEvent('#about-contracts');
+
+ component.processLinks(event);
+
+ expect((event.preventDefault as jasmine.Spy)).not.toHaveBeenCalled();
+ expect(navigateTo).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/static-page/static-page.component.ts b/src/app/static-page/static-page.component.ts
new file mode 100644
index 00000000000..6ca77bf955c
--- /dev/null
+++ b/src/app/static-page/static-page.component.ts
@@ -0,0 +1,131 @@
+import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core';
+import { HtmlContentService } from '../shared/html-content.service';
+import { BehaviorSubject } from 'rxjs';
+import { Router } from '@angular/router';
+import { isEmpty } from '../shared/empty.util';
+import { STATIC_PAGE_PATH } from './static-page-routing-paths';
+import { APP_CONFIG, AppConfig } from '../../config/app-config.interface';
+import { ServerResponseService } from '../core/services/server-response.service';
+
+/**
+ * Component which load and show static files from the `static-files` folder.
+ * E.g., `/static/test_file.html will load the file content from the `static-files/test_file.html`/
+ */
+@Component({
+ selector: 'ds-static-page',
+ templateUrl: './static-page.component.html',
+ styleUrls: ['./static-page.component.scss']
+})
+export class StaticPageComponent implements OnInit {
+ htmlContent: BehaviorSubject = new BehaviorSubject('');
+ htmlFileName: string;
+ contentState: 'loading' | 'found' | 'not-found' = 'loading';
+
+ constructor(private htmlContentService: HtmlContentService,
+ private router: Router,
+ private responseService: ServerResponseService,
+ private changeDetector: ChangeDetectorRef,
+ @Inject(APP_CONFIG) protected appConfig?: AppConfig) { }
+
+ async ngOnInit(): Promise {
+ try {
+ this.contentState = 'loading';
+ this.htmlContent.next('');
+
+ // Fetch html file name from the url path. `static/some_file.html`
+ this.htmlFileName = this.getHtmlFileName();
+
+ let htmlContent = await this.htmlContentService.getHmtlContentByPathAndLocale(this.htmlFileName);
+ if (htmlContent !== undefined) {
+ const restBase = this.appConfig?.rest?.baseUrl;
+ const oaiUrl = restBase ? restBase.replace(/\/+$/, '') + '/oai' : '/server/oai';
+ htmlContent = htmlContent.replace(/href="\/server\/oai/gi, 'href="' + oaiUrl);
+
+ this.htmlContent.next(htmlContent);
+ this.contentState = 'found';
+ this.changeDetector.detectChanges();
+ return;
+ }
+
+ // Content not found - set 404 status for SSR and show inline error
+ this.responseService.setNotFound();
+ this.contentState = 'not-found';
+ this.changeDetector.detectChanges();
+ } catch (error) {
+ console.error('Static page load error:', {
+ fileName: this.htmlFileName,
+ url: this.router.url,
+ error: error
+ });
+ this.responseService.setNotFound();
+ this.contentState = 'not-found';
+ this.changeDetector.detectChanges();
+ }
+ }
+
+ /**
+ * Handle click on links in the static page.
+ * @param event
+ */
+ processLinks(event: Event): void {
+ const targetElement = event.target as HTMLElement | null;
+ const anchorElement = targetElement?.closest?.('a');
+ if (!anchorElement) {
+ return;
+ }
+
+ const href = anchorElement.getAttribute('href');
+ if (!href || !this.isRelativeLink(href)) {
+ return;
+ }
+
+ event.preventDefault();
+ const namespacePrefix = this.getNamespacePrefix();
+ const staticPageBaseUrl = this.composeStaticPageBaseUrl(namespacePrefix);
+ this.redirectToRelativeLink(staticPageBaseUrl, href);
+ }
+
+ private getNamespacePrefix(): string {
+ const nameSpace = this.appConfig?.ui?.nameSpace ?? '/';
+ return nameSpace === '/' ? '' : nameSpace.replace(/\/$/, '');
+ }
+
+ private composeUrl(pathname: string): string {
+ const baseUrl = new URL(window.location.origin);
+ baseUrl.pathname = pathname;
+ return baseUrl.href;
+ }
+
+ private composeStaticPageBaseUrl(namespacePrefix: string): string {
+ return this.composeUrl(`${namespacePrefix}/${STATIC_PAGE_PATH}/`);
+ }
+
+ private isRelativeLink(href: string | null): boolean {
+ return href?.startsWith('.') ?? false;
+ }
+
+ private redirectToRelativeLink(redirectUrl: string, href: string | null): void {
+ this.navigateTo(new URL(href, redirectUrl).href);
+ }
+
+ private navigateTo(url: string): void {
+ window.location.href = url;
+ }
+
+ /**
+ * Load file name from the URL - `static/FILE_NAME.html`
+ * @private
+ */
+ private getHtmlFileName() {
+ let urlInList = this.router.url?.split('/');
+ // Filter empty elements
+ urlInList = urlInList.filter(n => n);
+ // if length is 1 - html file name wasn't defined.
+ if (isEmpty(urlInList) || urlInList.length === 1) {
+ return null;
+ }
+
+ // If the url is too long take just the first string after `/static` prefix.
+ return urlInList[1]?.split('#')?.[0];
+ }
+}
diff --git a/src/app/static-page/static-page.module.ts b/src/app/static-page/static-page.module.ts
new file mode 100644
index 00000000000..7ed011ead4a
--- /dev/null
+++ b/src/app/static-page/static-page.module.ts
@@ -0,0 +1,19 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+import { StaticPageRoutingModule } from './static-page-routing.module';
+import { StaticPageComponent } from './static-page.component';
+import { SharedModule } from '../shared/shared.module';
+
+
+@NgModule({
+ declarations: [
+ StaticPageComponent
+ ],
+ imports: [
+ CommonModule,
+ StaticPageRoutingModule,
+ SharedModule,
+ ]
+})
+export class StaticPageModule { }
diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5
index 32d958e88df..af60b1907a1 100644
--- a/src/assets/i18n/cs.json5
+++ b/src/assets/i18n/cs.json5
@@ -7204,6 +7204,15 @@
// "sorting.lastModified.DESC" : "Last modified Descending"
"sorting.lastModified.DESC" : "Poslední změna sestupně",
+ // "static-page.404.help" : "The static page you requested does not exist. It may have been moved or deleted. You can use the button below to get back to the home page."
+ "static-page.404.help" : "Požadovaná statická stránka neexistuje. Mohla být přesunuta nebo smazána. Pomocí níže uvedeného tlačítka se můžete vrátit na domovskou stránku.",
+
+ // "static-page.404.link.home-page" : "Take me to the home page"
+ "static-page.404.link.home-page" : "Návrat na domovskou stránku",
+
+ // "static-page.404.page-not-found" : "page not found"
+ "static-page.404.page-not-found" : "stránka nebyla nalezena",
+
// "statistics.title" : "Statistics"
"statistics.title" : "Statistiky",
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5
index c95e0576de5..0751b2f91f4 100644
--- a/src/assets/i18n/en.json5
+++ b/src/assets/i18n/en.json5
@@ -4443,6 +4443,13 @@
"sorting.lastModified.DESC": "Last modified Descending",
+ "static-page.404.help": "The static page you requested does not exist. It may have been moved or deleted. You can use the button below to get back to the home page.",
+
+ "static-page.404.link.home-page": "Take me to the home page",
+
+ "static-page.404.page-not-found": "page not found",
+
+
"statistics.title": "Statistics",
"statistics.header": "Statistics for {{ scope }}",
diff --git a/src/static-files/VERSION_D.html b/src/static-files/VERSION_D.html
new file mode 100644
index 00000000000..dd4019e2622
--- /dev/null
+++ b/src/static-files/VERSION_D.html
@@ -0,0 +1,7 @@
+