Skip to content
Merged
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
15 changes: 15 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,18 @@ accessibility:
matomo:
hostUrl: http://localhost:8135/
siteId: 1

# Values substituted for `${key}` placeholders in static pages served from `static-files`
# (e.g. `${dspace.name}` in terms-of-service.html). Values are plain text; HTML is escaped on insert.
# A missing or empty value leaves the `${key}` placeholder as is and logs a warning.
# Placeholders are substituted in text content only. Do not use them inside
# href, src, or other attribute values.
# You can override a single key; the other defaults are kept.
# Keys contain dots, so set them in this YAML file rather than through environment variables.
#staticPage:
# variables:
# dspace.name: LINDAT/CLARIAH-CZ Repository
# lr.description.institution: Institute of Formal and Applied Linguistics
# lr.description.location: Malostranské náměstí 25, 118 00 Praha, Czech Republic
# lr.description.city: Prague
# lr.description.country: Czech Republic
150 changes: 147 additions & 3 deletions src/app/static-page/static-page.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { StaticPageComponent } from './static-page.component';
import { HtmlContentService } from '../shared/html-content.service';
Expand All @@ -10,6 +10,7 @@ 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';
import { StaticPageConfig } from '../../config/static-page-config.interface';

describe('StaticPageComponent', () => {
function createDeferred<T>() {
Expand All @@ -26,7 +27,8 @@ describe('StaticPageComponent', () => {
html: string | undefined,
restBase?: string,
contentPromise?: Promise<string | undefined>,
route: string = '/static/test-file.html'
route: string = '/static/test-file.html',
staticPage?: StaticPageConfig
) {
const htmlContentService = jasmine.createSpyObj('htmlContentService', {
fetchHtmlContent: of(html),
Expand All @@ -49,7 +51,8 @@ describe('StaticPageComponent', () => {
rest: {
...(environment as any).rest,
baseUrl: restBase
}
},
staticPage
};

await TestBed.configureTestingModule({
Expand Down Expand Up @@ -167,6 +170,147 @@ describe('StaticPageComponent', () => {
expect(component.htmlContent.value).toBe(otherHtml);
});

describe('variable interpolation', () => {
const allPlaceholdersHtml = '<p>Welcome to ${dspace.name}, provided by ${lr.description.institution}, ' +
'located at ${lr.description.location}.</p><p>Laws of ${lr.description.country}, court of ${lr.description.city}.</p>';
const allVariables = {
'dspace.name': 'Test Repository',
'lr.description.institution': 'Test Institute',
'lr.description.location': 'Test Street 1, 100 00 Test City',
'lr.description.city': 'Test City',
'lr.description.country': 'Test Country',
};

function setupInterpolationTest(html: string, variables?: { [key: string]: string }, restBase?: string) {
const staticPage = variables === undefined ? undefined : { variables };
return setupTest(html, restBase, undefined, '/static/test-file.html', staticPage);
}

async function render(fixture: ComponentFixture<StaticPageComponent>) {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}

let warnSpy: jasmine.Spy;

beforeEach(() => {
warnSpy = spyOn(console, 'warn');
});

it('should replace all configured placeholders', async () => {
const { fixture, component } = await setupInterpolationTest(allPlaceholdersHtml, allVariables);
await render(fixture);

const content = component.htmlContent.value;
Object.values(allVariables).forEach((value) => expect(content).toContain(value));
expect(content).not.toContain('${');
expect(warnSpy).not.toHaveBeenCalled();
});

it('should leave an unknown placeholder literal and warn once', async () => {
const { fixture, component } = await setupInterpolationTest('<p>${dspace.name} ${foo.bar}</p>', allVariables);
await render(fixture);

expect(component.htmlContent.value).toBe('<p>Test Repository ${foo.bar}</p>');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith('Static page: unresolved variables', ['foo.bar']);
});

it('should treat an empty string value as missing', async () => {
const { fixture, component } = await setupInterpolationTest('<p>${dspace.name}</p>', { ...allVariables, 'dspace.name': '' });
await render(fixture);

expect(component.htmlContent.value).toBe('<p>${dspace.name}</p>');
expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['dspace.name']);
});

it('should treat a null value as missing', async () => {
// A YAML key without a value (`dspace.name:`) is loaded as null
const { fixture, component } = await setupInterpolationTest('<p>${dspace.name}</p>', { ...allVariables, 'dspace.name': null });
await render(fixture);

expect(component.htmlContent.value).toBe('<p>${dspace.name}</p>');
expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['dspace.name']);
});

it('should treat a non-string value as missing', async () => {
// YAML can load a number, e.g. `dspace.name: 123`
const { fixture, component } = await setupInterpolationTest('<p>${dspace.name}</p>', { ...allVariables, 'dspace.name': 123 } as any);
await render(fixture);

expect(component.htmlContent.value).toBe('<p>${dspace.name}</p>');
expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['dspace.name']);
});

it('should HTML-escape values', async () => {
const { fixture, component } = await setupInterpolationTest('<p id="target">${dspace.name}</p>', { 'dspace.name': 'A & <b>B</b>' });
await render(fixture);

expect(component.htmlContent.value).toBe('<p id="target">A &amp; &lt;b&gt;B&lt;/b&gt;</p>');
const target = fixture.nativeElement.querySelector('#target');
expect(target.querySelector('b')).toBeNull();
expect(target.textContent).toBe('A & <b>B</b>');
});

it('should insert values containing replacement patterns literally', async () => {
const { fixture, component } = await setupInterpolationTest(
'<p id="target">${dspace.name}|${lr.description.city}</p>',
{ 'dspace.name': 'a$&b', 'lr.description.city': 'c$1$`$\'d' }
);
await render(fixture);

expect(fixture.nativeElement.querySelector('#target').textContent).toBe('a$&b|c$1$`$\'d');
expect(component.htmlContent.value).not.toContain('${');
});

it('should leave content unchanged when staticPage config is missing', async () => {
const { fixture, component } = await setupInterpolationTest(allPlaceholdersHtml, undefined);
await render(fixture);

expect(component.contentState).toBe('found');
expect(component.htmlContent.value).toBe(allPlaceholdersHtml);
});

it('should leave all placeholders literal and warn once when the variables map is empty', async () => {
const { fixture, component } = await setupInterpolationTest(allPlaceholdersHtml, {});
await render(fixture);

expect(component.htmlContent.value).toBe(allPlaceholdersHtml);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.calls.mostRecent().args[1]).toEqual(jasmine.arrayWithExactContents(Object.keys(allVariables)));
});

it('should not touch other placeholder syntaxes', async () => {
const html = '<p>$dspace.name {{\'x\' | translate}} $ {dspace.name}</p>';
const { fixture, component } = await setupInterpolationTest(html, allVariables);
await render(fixture);

expect(component.htmlContent.value).toBe(html);
expect(warnSpy).not.toHaveBeenCalled();
});

it('should apply both the OAI link rewrite and variable interpolation', async () => {
const { fixture, component } = await setupInterpolationTest(
'<a href="/server/oai/request?verb=Identify">${dspace.name}</a>',
allVariables,
'https://api.example.org/repository/server'
);
await render(fixture);

expect(component.htmlContent.value)
.toBe('<a href="https://api.example.org/repository/server/oai/request?verb=Identify">Test Repository</a>');
});

it('should not resolve inherited object properties', async () => {
const { fixture, component } = await setupInterpolationTest('<p>${toString} ${constructor}</p>', allVariables);
await render(fixture);

expect(component.htmlContent.value).toBe('<p>${toString} ${constructor}</p>');
expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['toString', 'constructor']);
});
});

describe('contentState behavior', () => {
it('should initialize contentState to "loading"', async () => {
const { component } = await setupTest('<div>test</div>');
Expand Down
27 changes: 27 additions & 0 deletions src/app/static-page/static-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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';
import escapeHtml from 'lodash/escape';

/**
* Component which load and show static files from the `static-files` folder.
Expand Down Expand Up @@ -40,6 +41,7 @@ export class StaticPageComponent implements OnInit {
const restBase = this.appConfig?.rest?.baseUrl;
const oaiUrl = restBase ? restBase.replace(/\/+$/, '') + '/oai' : '/server/oai';
htmlContent = htmlContent.replace(/href="\/server\/oai/gi, 'href="' + oaiUrl);
htmlContent = this.interpolateVariables(htmlContent);

this.htmlContent.next(htmlContent);
this.contentState = 'found';
Expand Down Expand Up @@ -85,6 +87,31 @@ export class StaticPageComponent implements OnInit {
this.redirectToRelativeLink(staticPageBaseUrl, href);
}

/**
* Replace `${key}` placeholders with values from `staticPage.variables` in the app config.
* Values are HTML-escaped. Missing, null, non-string, or empty values leave the placeholder unchanged.
* @param html static page content
* @private
*/
private interpolateVariables(html: string): string {
const vars = this.appConfig?.staticPage?.variables ?? {};
const missing = new Set<string>();
// Function replacer: `$&`, `$'` etc. in values are inserted literally, not as replacement patterns.
const out = html.replace(/\$\{([\w.-]+)\}/g, (match: string, key: string) => {
// Own-property check, so `${toString}` does not resolve to an inherited member.
const value = Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : undefined;
if (typeof value !== 'string' || value.trim() === '') {
missing.add(key);
return match;
}
return escapeHtml(value);
});
if (missing.size > 0) {
console.warn('Static page: unresolved variables', [...missing]);
}
return out;
}

private getNamespacePrefix(): string {
const nameSpace = this.appConfig?.ui?.nameSpace ?? '/';
return nameSpace === '/' ? '' : nameSpace.replace(/\/$/, '');
Expand Down
2 changes: 2 additions & 0 deletions src/config/app-config.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { SearchConfig } from './search-page-config.interface';
import { AccessibilitySettingsConfig } from '../app/accessibility/accessibility-settings.config';
import { MatomoConfig } from './matomo-config';
import { StatisticsConfig } from './statistics-config';
import { StaticPageConfig } from './static-page-config.interface';

interface AppConfig extends Config {
ui: UIServerConfig;
Expand Down Expand Up @@ -59,6 +60,7 @@ interface AppConfig extends Config {
signpostingEnabled: boolean;
matomo: MatomoConfig;
statistics?: StatisticsConfig;
staticPage?: StaticPageConfig;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/config/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ export const buildAppConfig = (destConfigPath?: string): AppConfig => {
search: appConfig.search,
accessibility: appConfig.accessibility,
signpostingEnabled: appConfig.signpostingEnabled,
// Static page placeholder values (e.g. terms-of-service.html), needed for client-side rendering
staticPage: appConfig.staticPage,
// Matomo analytics - only expose client-side tracking properties
...(appConfig.matomo && {
matomo: {
Expand Down
17 changes: 16 additions & 1 deletion src/config/config.util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { environment } from '../environments/environment.production';
import { extendEnvironmentWithAppConfig } from './config.util';
import { extendEnvironmentWithAppConfig, mergeConfig } from './config.util';
import { DefaultAppConfig } from './default-app-config';
import { HandleThemeConfig } from './theme.config';

Expand Down Expand Up @@ -57,4 +57,19 @@ describe('Config Util', () => {
expect((environment.themes[1] as HandleThemeConfig).handle).toEqual(customTheme.handle);
});
});

describe('mergeConfig', () => {
it('should override a single static page variable and keep the other defaults', () => {
const appConfig = new DefaultAppConfig();
const defaults = { ...appConfig.staticPage.variables };

mergeConfig(appConfig, { staticPage: { variables: { 'dspace.name': 'X' } } } as any);

expect(appConfig.staticPage.variables['dspace.name']).toEqual('X');
Object.keys(defaults)
.filter((key) => key !== 'dspace.name')
.forEach((key) => expect(appConfig.staticPage.variables[key]).toEqual(defaults[key]));
expect(Object.keys(appConfig.staticPage.variables).length).toEqual(5);
});
});
});
13 changes: 13 additions & 0 deletions src/config/default-app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
import { SearchConfig } from './search-page-config.interface';
import { AccessibilitySettingsConfig } from '../app/accessibility/accessibility-settings.config';
import { MatomoConfig } from './matomo-config';
import { StaticPageConfig } from './static-page-config.interface';

export class DefaultAppConfig implements AppConfig {
production = false;
Expand Down Expand Up @@ -456,4 +457,16 @@ export class DefaultAppConfig implements AppConfig {
siteId: '1',
dimensionId: 1
};

// Values substituted for `${key}` placeholders in static pages (e.g. terms-of-service.html).
// Plain text; HTML is escaped on insert. A missing or empty value leaves the placeholder as is.
staticPage: StaticPageConfig = {
variables: {
'dspace.name': 'LINDAT/CLARIAH-CZ Repository',
'lr.description.institution': 'Institute of Formal and Applied Linguistics',
'lr.description.location': 'Malostranské náměstí 25, 118 00 Praha, Czech Republic',
'lr.description.city': 'Prague',
'lr.description.country': 'Czech Republic',
}
};
}
13 changes: 13 additions & 0 deletions src/config/static-page-config.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Config } from './config.interface';

/**
* Configuration for static pages served from the `static-files` folder.
*/
export class StaticPageConfig implements Config {
/**
* Values substituted for `${key}` placeholders in static page HTML, e.g. `${dspace.name}`.
* Values are plain text; they are HTML-escaped on insert.
* Placeholders are for text content only, not for URL or attribute values.
*/
public variables: { [key: string]: string } = {};
}
Loading