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
12 changes: 11 additions & 1 deletion src/app/item-page/full/full-item-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,17 @@
@for (mdValue of mdEntry.value; track mdValue) {
<tr>
<td>{{mdEntry.key}}</td>
<td>{{mdValue.value}}</td>
<td>
@if (getMetadataLink(mdEntry.key, mdValue.value); as link) {
@if (link.external) {
<a [href]="link.href" target="_blank" rel="noopener noreferrer">{{mdValue.value}}</a>
} @else {
<a [routerLink]="link.routerLink" [queryParams]="link.queryParams">{{mdValue.value}}</a>
}
} @else {
<span [innerHTML]="makeLinks(mdValue.value)"></span>
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[innerHTML]="makeLinks(mdValue.value)" changes escaping semantics compared to {{mdValue.value}}: any HTML present in metadata values will now be rendered (after Angular sanitization) instead of displayed as plain text. If metadata is not meant to support HTML markup, consider avoiding innerHTML here and rendering links via bindings (or escaping the non-URL parts before inserting <a> tags) so the rest of the value remains text.

Suggested change
<span [innerHTML]="makeLinks(mdValue.value)"></span>
<span>{{mdValue.value}}</span>

Copilot uses AI. Check for mistakes.
}
</td>
<td>{{mdValue.language}}</td>
</tr>
}
Expand Down
140 changes: 140 additions & 0 deletions src/app/item-page/full/full-item-page.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,72 @@ const mockItem: Item = Object.assign(new Item(), {
},
});

const mockItemWithUrl: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: {
'dc.title': [
{
language: 'en_US',
value: 'test item',
},
],
'dc.identifier.uri': [
{
language: null,
value: 'https://hdl.handle.net/123456789/1',
},
],
'dc.description': [
{
language: 'en_US',
value: 'plain text value',
},
],
},
});

const mockItemWithSpecialFields: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: {
'dc.title': [
{
language: 'en_US',
value: 'test item',
},
],
'local.identifier.doi': [
{
language: null,
value: '10.1234/test',
},
],
'local.identifier.scopus': [
{
language: null,
value: '2-s2.0-85012345678',
},
],
'local.identifier.wos': [
{
language: null,
value: 'WOS:000123456789',
},
],
'dc.subject': [
{
language: 'en_US',
value: 'Mathematics',
},
],
'dc.contributor.author': [
{
language: null,
value: 'Novák, Jan',
},
],
},
});

const mockWithdrawnItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: [],
Expand Down Expand Up @@ -265,4 +331,78 @@ describe('FullItemPageComponent', () => {
expect(linkHeadService.addTag).toHaveBeenCalledTimes(3);
});
});

describe('metadata URL rendering', () => {
beforeEach(() => {
routeData.dso = createSuccessfulRemoteDataObject(mockItemWithUrl);
comp.ngOnInit();
fixture.detectChanges();
});

it('should render URL metadata values as clickable links', () => {
const links = fixture.debugElement.queryAll(By.css('table a'));
const urlLink = links.find(l => l.nativeElement.textContent.includes('https://hdl.handle.net/123456789/1'));
expect(urlLink).toBeTruthy();
expect(urlLink.nativeElement.getAttribute('href')).toBe('https://hdl.handle.net/123456789/1');
expect(urlLink.nativeElement.getAttribute('target')).toBe('_blank');
expect(urlLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer');
});

it('should render non-URL metadata values as plain text without links', () => {
const table = fixture.debugElement.query(By.css('table'));
expect(table.nativeElement.innerHTML).toContain('plain text value');
const links = fixture.debugElement.queryAll(By.css('table a'));
const plainTextLink = links.find(l => l.nativeElement.textContent.includes('plain text value'));
expect(plainTextLink).toBeFalsy();
});
});

describe('field-specific metadata link rendering', () => {
beforeEach(() => {
routeData.dso = createSuccessfulRemoteDataObject(mockItemWithSpecialFields);
comp.ngOnInit();
fixture.detectChanges();
});

it('should render bare DOI as external link to doi.org', () => {
const links = fixture.debugElement.queryAll(By.css('table a'));
const doiLink = links.find(l => l.nativeElement.textContent.includes('10.1234/test'));
expect(doiLink).toBeTruthy();
expect(doiLink.nativeElement.getAttribute('href')).toContain('https://doi.org/');
expect(doiLink.nativeElement.getAttribute('target')).toBe('_blank');
expect(doiLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer');
});

it('should render Scopus ID as external link', () => {
const links = fixture.debugElement.queryAll(By.css('table a'));
const scopusLink = links.find(l => l.nativeElement.textContent.includes('2-s2.0-85012345678'));
expect(scopusLink).toBeTruthy();
expect(scopusLink.nativeElement.getAttribute('href')).toContain('scopus.com');
expect(scopusLink.nativeElement.getAttribute('target')).toBe('_blank');
});

it('should render WOS ID as external link', () => {
const links = fixture.debugElement.queryAll(By.css('table a'));
const wosLink = links.find(l => l.nativeElement.textContent.includes('WOS:000123456789'));
expect(wosLink).toBeTruthy();
expect(wosLink.nativeElement.getAttribute('href')).toContain('webofscience.com');
expect(wosLink.nativeElement.getAttribute('target')).toBe('_blank');
});

it('should render dc.subject as internal search link', () => {
const links = fixture.debugElement.queryAll(By.css('table a'));
const subjectLink = links.find(l => l.nativeElement.textContent.includes('Mathematics'));
expect(subjectLink).toBeTruthy();
expect(subjectLink.nativeElement.getAttribute('href')).toContain('/search');
expect(subjectLink.nativeElement.getAttribute('target')).toBeNull();
});

it('should render dc.contributor.author as internal search link', () => {
const links = fixture.debugElement.queryAll(By.css('table a'));
const authorLink = links.find(l => l.nativeElement.textContent.trim().includes('Nov'));
expect(authorLink).toBeTruthy();
expect(authorLink.nativeElement.getAttribute('href')).toContain('/search');
expect(authorLink.nativeElement.getAttribute('target')).toBeNull();
});
});
});
6 changes: 6 additions & 0 deletions src/app/item-page/full/full-item-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-ed
import { hasValue } from '../../shared/empty.util';
import { ErrorComponent } from '../../shared/error/error.component';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import {
getMetadataLink,
makeLinks,
} from '../../shared/utils/make-links';
import { VarDirective } from '../../shared/utils/var.directive';
import { CollectionsComponent } from '../field-components/collections/collections.component';
import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component';
Expand Down Expand Up @@ -78,6 +82,8 @@ import { ThemedFullFileSectionComponent } from './field-components/file-section/
standalone: true,
})
export class FullItemPageComponent extends ItemPageComponent implements OnInit, OnDestroy {
protected readonly makeLinks = makeLinks;
protected readonly getMetadataLink = getMetadataLink;

itemRD$: BehaviorSubject<RemoteData<Item>>;

Expand Down
169 changes: 169 additions & 0 deletions src/app/shared/utils/make-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import {
getMetadataLink,
makeLinks,
} from './make-links';

describe('makeLinks', () => {
it('should convert https URLs to clickable links', () => {
expect(makeLinks('https://example.com')).toContain('<a href="https://example.com"');
expect(makeLinks('https://example.com')).toContain('target="_blank"');
});

it('should convert http URLs to clickable links', () => {
expect(makeLinks('http://example.com')).toContain('<a href="http://example.com"');
});

it('should convert ftp URLs to clickable links', () => {
expect(makeLinks('ftp://files.example.com/resource')).toContain('<a href="ftp://files.example.com/resource"');
});

it('should convert www. URLs to clickable links with https:// prefix in href', () => {
const result = makeLinks('www.example.com');
expect(result).toContain('<a href="https://www.example.com"');
expect(result).toContain('>www.example.com</a>');
});

it('should return plain text unchanged (HTML-escaped)', () => {
expect(makeLinks('just some text')).toBe('just some text');
});

it('should handle null/undefined gracefully', () => {
expect(makeLinks(null)).toBeUndefined();
expect(makeLinks(undefined)).toBeUndefined();
});

it('should handle empty string', () => {
expect(makeLinks('')).toBe('');
});

it('should convert URLs embedded in text', () => {
const result = makeLinks('Visit https://example.com for details');
expect(result).toContain('<a href="https://example.com"');
expect(result).toContain('Visit');
expect(result).toContain('for details');
});

it('should handle DOI / handle redirect URLs', () => {
const result = makeLinks('https://hdl.handle.net/123456789/1');
expect(result).toContain('<a href="https://hdl.handle.net/123456789/1"');
expect(result).toContain('rel="noopener noreferrer"');
});

it('should handle DOI resolver URLs', () => {
const result = makeLinks('https://doi.org/10.1234/test');
expect(result).toContain('<a href="https://doi.org/10.1234/test"');
});

it('should not create links for javascript: URIs', () => {
const result = makeLinks('javascript:alert(1)');
expect(result).not.toContain('<a');
});

it('should not create links for data: URIs', () => {
const result = makeLinks('data:text/html,<script>alert(1)</script>');
expect(result).not.toContain('<a');
});

it('should handle multiple URLs in one string', () => {
const result = makeLinks('See https://a.com and https://b.com');
expect(result).toContain('<a href="https://a.com"');
expect(result).toContain('<a href="https://b.com"');
});

it('should handle URLs with query parameters', () => {
const result = makeLinks('https://example.com/search?q=test&page=1');
expect(result).toContain('href="https://example.com/search?q=test&amp;page=1"');
});

it('should handle URLs with fragments', () => {
const result = makeLinks('https://example.com/page#section');
expect(result).toContain('<a href="https://example.com/page#section"');
});

it('should handle URLs with paths', () => {
const result = makeLinks('https://example.com/path/to/resource');
expect(result).toContain('<a href="https://example.com/path/to/resource"');
});

it('should stop URL at closing parenthesis', () => {
const result = makeLinks('(https://example.com)');
expect(result).toContain('<a href="https://example.com"');
expect(result).toContain('(');
expect(result).toMatch(/\)$/);
});

it('should HTML-escape non-URL parts to prevent markup injection', () => {
const result = makeLinks('<b>bold</b> https://example.com');
expect(result).toContain('&lt;b&gt;bold&lt;/b&gt;');
expect(result).toContain('<a href="https://example.com"');
});

it('should HTML-escape ampersands in plain text', () => {
const result = makeLinks('A & B');
expect(result).toBe('A &amp; B');
});
});

describe('getMetadataLink', () => {
it('should return DOI resolver link for bare DOI', () => {
const link = getMetadataLink('local.identifier.doi', '10.1234/test');
expect(link).toBeTruthy();
expect(link.external).toBeTrue();
expect(link.href).toBe('https://doi.org/10.1234%2Ftest');
});

it('should return null for DOI that is already a full URL', () => {
expect(getMetadataLink('local.identifier.doi', 'https://doi.org/10.1234/test')).toBeNull();
});

it('should return Scopus link for Scopus ID', () => {
const link = getMetadataLink('local.identifier.scopus', '2-s2.0-85012345678');
expect(link).toBeTruthy();
expect(link.external).toBeTrue();
expect(link.href).toBe('https://www.scopus.com/record/display.uri?eid=2-s2.0-85012345678');
});

it('should return WOS link for WOS ID', () => {
const link = getMetadataLink('local.identifier.wos', 'WOS:000123456789');
expect(link).toBeTruthy();
expect(link.external).toBeTrue();
expect(link.href).toBe('https://www.webofscience.com/wos/woscc/full-record/WOS%3A000123456789');
});

it('should return internal search link for dc.subject', () => {
const link = getMetadataLink('dc.subject', 'Mathematics');
expect(link).toBeTruthy();
expect(link.external).toBeFalse();
expect(link.routerLink).toBe('/search');
expect(link.queryParams).toEqual({ 'f.subject': 'Mathematics,equals' });
});

it('should return internal search link for dc.contributor.author', () => {
const link = getMetadataLink('dc.contributor.author', 'Novák, Jan');
expect(link).toBeTruthy();
expect(link.external).toBeFalse();
expect(link.routerLink).toBe('/search');
expect(link.queryParams).toEqual({ 'f.author': 'Nov\u00e1k, Jan,equals' });
});

it('should return null for non-special metadata fields', () => {
expect(getMetadataLink('dc.title', 'some title')).toBeNull();
expect(getMetadataLink('dc.description', 'some description')).toBeNull();
});

it('should return null for empty or null values', () => {
expect(getMetadataLink('local.identifier.doi', '')).toBeNull();
expect(getMetadataLink('local.identifier.doi', null)).toBeNull();
expect(getMetadataLink('local.identifier.doi', undefined)).toBeNull();
});

it('should trim whitespace from values', () => {
const link = getMetadataLink('local.identifier.doi', ' 10.1234/test ');
expect(link.href).toBe('https://doi.org/10.1234%2Ftest');
});

it('should not double-append operator if value already has one', () => {
const link = getMetadataLink('dc.subject', 'Mathematics,equals');
expect(link.queryParams['f.subject']).toBe('Mathematics,equals');
});
});
Loading
Loading