diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html
index 0e0db894b1d..d667a8f3e9c 100644
--- a/src/app/item-page/full/full-item-page.component.html
+++ b/src/app/item-page/full/full-item-page.component.html
@@ -24,7 +24,17 @@
@for (mdValue of mdEntry.value; track mdValue) {
| {{mdEntry.key}} |
- {{mdValue.value}} |
+
+ @if (getMetadataLink(mdEntry.key, mdValue.value); as link) {
+ @if (link.external) {
+ {{mdValue.value}}
+ } @else {
+ {{mdValue.value}}
+ }
+ } @else {
+
+ }
+ |
{{mdValue.language}} |
}
diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts
index ac2b4634a83..dbc949bd2f9 100644
--- a/src/app/item-page/full/full-item-page.component.spec.ts
+++ b/src/app/item-page/full/full-item-page.component.spec.ts
@@ -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: [],
@@ -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();
+ });
+ });
});
diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts
index 87b01f21e97..2b7da8df65a 100644
--- a/src/app/item-page/full/full-item-page.component.ts
+++ b/src/app/item-page/full/full-item-page.component.ts
@@ -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';
@@ -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>;
diff --git a/src/app/shared/utils/make-links.spec.ts b/src/app/shared/utils/make-links.spec.ts
new file mode 100644
index 00000000000..14a737c889b
--- /dev/null
+++ b/src/app/shared/utils/make-links.spec.ts
@@ -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(' {
+ expect(makeLinks('http://example.com')).toContain(' {
+ expect(makeLinks('ftp://files.example.com/resource')).toContain(' {
+ const result = makeLinks('www.example.com');
+ expect(result).toContain('www.example.com');
+ });
+
+ 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(' {
+ const result = makeLinks('https://hdl.handle.net/123456789/1');
+ expect(result).toContain(' {
+ const result = makeLinks('https://doi.org/10.1234/test');
+ expect(result).toContain(' {
+ const result = makeLinks('javascript:alert(1)');
+ expect(result).not.toContain(' {
+ const result = makeLinks('data:text/html,');
+ expect(result).not.toContain(' {
+ const result = makeLinks('See https://a.com and https://b.com');
+ expect(result).toContain(' {
+ const result = makeLinks('https://example.com/search?q=test&page=1');
+ expect(result).toContain('href="https://example.com/search?q=test&page=1"');
+ });
+
+ it('should handle URLs with fragments', () => {
+ const result = makeLinks('https://example.com/page#section');
+ expect(result).toContain(' {
+ const result = makeLinks('https://example.com/path/to/resource');
+ expect(result).toContain(' {
+ const result = makeLinks('(https://example.com)');
+ expect(result).toContain(' {
+ const result = makeLinks('bold https://example.com');
+ expect(result).toContain('<b>bold</b>');
+ expect(result).toContain(' {
+ const result = makeLinks('A & B');
+ expect(result).toBe('A & 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');
+ });
+});
diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts
new file mode 100644
index 00000000000..e3fe68984ba
--- /dev/null
+++ b/src/app/shared/utils/make-links.ts
@@ -0,0 +1,111 @@
+import { addOperatorToFilterValue } from '../search/search.utils';
+
+/**
+ * Escape HTML special characters so that non-URL parts of a metadata value
+ * are rendered as plain text when inserted via [innerHTML].
+ */
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Convert raw text URLs into clickable HTML links.
+ * Detects http, https, ftp URLs and www. addresses.
+ * Non-URL parts are HTML-escaped to prevent markup injection via [innerHTML].
+ *
+ * Ported from dtq-dev clarin-shared-util.ts to be reused across components.
+ */
+export function makeLinks(text: string | null | undefined): string | undefined {
+ if (text == null) {
+ return undefined;
+ }
+ const regex = /(?:https?|ftp):\/\/[^\s)]+|www\.[^\s)]+/g;
+ let result = '';
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(text)) !== null) {
+ // Escape the plain text segment before this URL
+ result += escapeHtml(text.slice(lastIndex, match.index));
+ const url = match[0];
+ const href = url.startsWith('www.') ? `https://${url}` : url;
+ result += `${escapeHtml(url)}`;
+ lastIndex = regex.lastIndex;
+ }
+
+ // Escape the remaining plain text after the last URL
+ result += escapeHtml(text.slice(lastIndex));
+ return result;
+}
+
+/**
+ * Metadata link descriptor returned by getMetadataLink().
+ */
+export interface MetadataLink {
+ external: boolean;
+ /** Full URL for external links */
+ href?: string;
+ /** Router path for internal links */
+ routerLink?: string;
+ /** Query parameters for internal links */
+ queryParams?: Record;
+}
+
+// --- External resolver base URLs ---
+const DOI_RESOLVER = 'https://doi.org/';
+const SCOPUS_RECORD = 'https://www.scopus.com/record/display.uri?eid=';
+const WOS_RECORD = 'https://www.webofscience.com/wos/woscc/full-record/';
+
+// --- Internal search configuration ---
+const SEARCH_PATH = '/search';
+
+/** Maps metadata keys to their corresponding search filter parameter. */
+const SEARCH_FIELD_FILTERS: Record = {
+ 'dc.subject': 'f.subject',
+ 'dc.contributor.author': 'f.author',
+};
+
+/** Maps metadata keys to their external resolver base URL. */
+const EXTERNAL_RESOLVERS: Record = {
+ 'local.identifier.scopus': SCOPUS_RECORD,
+ 'local.identifier.wos': WOS_RECORD,
+};
+
+const HTTP_URL_PATTERN = /^https?:\/\//i;
+
+/**
+ * For specific metadata fields, build an appropriate hyperlink.
+ * Returns null when the field needs no special treatment (fall back to makeLinks).
+ */
+export function getMetadataLink(key: string, value: string | null | undefined): MetadataLink | null {
+ const trimmed = value?.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ // DOI: bare identifiers → doi.org resolver; full URLs fall through to makeLinks
+ if (key === 'local.identifier.doi') {
+ return HTTP_URL_PATTERN.test(trimmed)
+ ? null
+ : { external: true, href: `${DOI_RESOLVER}${encodeURIComponent(trimmed)}` };
+ }
+
+ // External resolvers (Scopus, WOS, …)
+ const resolver = EXTERNAL_RESOLVERS[key];
+ if (resolver) {
+ return { external: true, href: `${resolver}${encodeURIComponent(trimmed)}` };
+ }
+
+ // Internal search links (subject, author, …)
+ const filterParam = SEARCH_FIELD_FILTERS[key];
+ if (filterParam) {
+ return { external: false, routerLink: SEARCH_PATH, queryParams: { [filterParam]: addOperatorToFilterValue(trimmed, 'equals') } };
+ }
+
+ return null;
+}