From 83b63fdfae3ff382160ef9009829c0e12998c60e Mon Sep 17 00:00:00 2001
From: kosarko
Date: Thu, 4 Jun 2026 17:35:10 +0200
Subject: [PATCH] Normalize metadata language to BCP 47 for HTML lang
attributes
PR #139 binds [attr.lang] straight from the DSpace metadata language field,
which is stored as Java-style locales (en_US, cs_CZ) or the wildcard *. None
of those are valid BCP 47 / HTML lang values, so assistive technology ignores
them and the accessibility improvement is inert for the common stored values.
Add a single shared metadataLangToBcp47() helper (*/empty/nullish -> null,
"_" -> "-") and a thin dsLang pipe that delegates to it. Route all of PR #139's
lang bindings through it: the pipe in the seven templates, the helper directly
in the three .ts sites. Declare DsLangPipe in the specs that render the pipe
(NO_ERRORS_SCHEMA does not suppress missing pipes), including the shared grid
element test factory. Add unit specs for the helper and pipe plus a DOM
assertion that en_US renders as lang="en-US".
---
...-search-result-grid-element.component.html | 2 +-
...-search-result-grid-element.component.html | 2 +-
...-search-result-grid-element.component.html | 2 +-
...-search-result-list-element.component.html | 2 +-
...arch-result-list-element.component.spec.ts | 5 ++--
...t-sidebar-search-list-element.component.ts | 3 +-
...-item-metadata-list-element.component.html | 2 +-
...em-metadata-list-element.component.spec.ts | 3 +-
.../metadata-values.component.html | 2 +-
.../metadata-values.component.spec.ts | 9 ++++++
.../metadata-values.component.ts | 10 +++++++
.../full-file-section.component.html | 4 +--
.../full-file-section.component.spec.ts | 3 +-
...clarin-description-item-field.component.ts | 3 +-
.../clarin-item-box-view.component.ts | 3 +-
...arch-result-grid-element.component.spec.ts | 3 +-
src/app/shared/shared.module.ts | 4 ++-
src/app/shared/utils/ds-lang.pipe.spec.ts | 25 ++++++++++++++++
src/app/shared/utils/ds-lang.pipe.ts | 24 +++++++++++++++
.../utils/metadata-language.util.spec.ts | 30 +++++++++++++++++++
.../shared/utils/metadata-language.util.ts | 20 +++++++++++++
21 files changed, 144 insertions(+), 17 deletions(-)
create mode 100644 src/app/shared/utils/ds-lang.pipe.spec.ts
create mode 100644 src/app/shared/utils/ds-lang.pipe.ts
create mode 100644 src/app/shared/utils/metadata-language.util.spec.ts
create mode 100644 src/app/shared/utils/metadata-language.util.ts
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
index 86b6328a8ed..b7b0ac8e340 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
@@ -31,7 +31,7 @@
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
index 102f1967d8e..3730a78755c 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
@@ -35,7 +35,7 @@
-
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
index 07a01da8df8..213ea3ea82d 100644
--- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
+++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
@@ -25,7 +25,7 @@
-
+
diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html
index 4c48b4c28d2..7d3f0d0926f 100644
--- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html
+++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html
@@ -31,7 +31,7 @@
0"
class="item-list-org-unit-description">
+ [innerHTML]="firstMetadataValue('dc.description')" [attr.lang]="dso.firstMetadata('dc.description')?.language | dsLang">
diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts
index 9609a9582aa..42e3aa372ed 100644
--- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts
+++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts
@@ -5,6 +5,7 @@ import { of as observableOf } from 'rxjs';
import { OrgUnitSearchResultListElementComponent } from './org-unit-search-result-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe';
+import { DsLangPipe } from '../../../../../shared/utils/ds-lang.pipe';
import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
@@ -75,7 +76,7 @@ describe('OrgUnitSearchResultListElementComponent', () => {
}
}
)],
- declarations: [ OrgUnitSearchResultListElementComponent , TruncatePipe],
+ declarations: [ OrgUnitSearchResultListElementComponent , TruncatePipe, DsLangPipe],
providers: [
{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock },
@@ -145,7 +146,7 @@ describe('OrgUnitSearchResultListElementComponent', () => {
}
}
)],
- declarations: [OrgUnitSearchResultListElementComponent, TruncatePipe],
+ declarations: [OrgUnitSearchResultListElementComponent, TruncatePipe, DsLangPipe],
providers: [
{provide: TruncatableService, useValue: {}},
{provide: DSONameService, useClass: DSONameServiceMock},
diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts
index 35dc045dda6..24ff750cabb 100644
--- a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts
+++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts
@@ -5,6 +5,7 @@ import { ItemSearchResult } from '../../../../../shared/object-collection/shared
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
+import { metadataLangToBcp47 } from '../../../../../shared/utils/metadata-language.util';
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@@ -29,6 +30,6 @@ export class OrgUnitSidebarSearchListElementComponent extends SidebarSearchListE
* Get the language of the Org Unit description.
*/
getDescriptionLang(): string | null {
- return this.dso.firstMetadata('dc.description')?.language || null;
+ return metadataLangToBcp47(this.dso.firstMetadata('dc.description')?.language);
}
}
diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html
index 7ca6ca2c02e..f7eb2692ac0 100644
--- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html
+++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html
@@ -1,7 +1,7 @@
-
+
diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts
index 429f2986b94..2099ea96da5 100644
--- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts
+++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts
@@ -8,6 +8,7 @@ import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-rep
import { OrgUnitItemMetadataListElementComponent } from './org-unit-item-metadata-list-element.component';
import { Item } from '../../../../core/shared/item.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
+import { DsLangPipe } from '../../../../shared/utils/ds-lang.pipe';
const description = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.';
const organisation = 'Anonymous';
@@ -24,7 +25,7 @@ describe('OrgUnitItemMetadataListElementComponent', () => {
imports:[
NgbModule
],
- declarations: [OrgUnitItemMetadataListElementComponent],
+ declarations: [OrgUnitItemMetadataListElementComponent, DsLangPipe],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(OrgUnitItemMetadataListElementComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.html b/src/app/item-page/field-components/metadata-values/metadata-values.component.html
index f2b21db938c..102301beb3f 100644
--- a/src/app/item-page/field-components/metadata-values/metadata-values.component.html
+++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.html
@@ -4,7 +4,7 @@
Choose a template. Priority: markdown, link, browse link.
-->
+ context: {value: mdValue.value, lang: getLang(mdValue)}">
diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts
index de46cd9e4c8..223f409d286 100644
--- a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts
+++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts
@@ -63,6 +63,15 @@ describe('MetadataValuesComponent', () => {
}
});
+ it('should render the metadata language as a normalized BCP 47 lang attribute', () => {
+ const langEls = fixture.debugElement.queryAll(By.css('[lang]'));
+ expect(langEls.length).toBeGreaterThan(0);
+ langEls.forEach((el) => {
+ expect(el.nativeElement.getAttribute('lang')).toEqual('en-US');
+ });
+ expect(fixture.nativeElement.innerHTML).not.toContain('lang="en_US"');
+ });
+
it('should contain separators equal to the amount of metadata values minus one', () => {
const separators = fixture.debugElement.queryAll(By.css('span.separator'));
expect(separators.length).toBe(mockMetadata.length - 1);
diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts
index a902e48ed1d..f61aac08a47 100644
--- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts
+++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts
@@ -5,6 +5,7 @@ import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { hasValue } from '../../../shared/empty.util';
import { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type';
import { environment } from '../../../../environments/environment';
+import { metadataLangToBcp47 } from '../../../shared/utils/metadata-language.util';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -112,4 +113,13 @@ export class MetadataValuesComponent implements OnChanges {
return { target: '_blank', rel: 'noopener noreferrer' };
}
}
+
+ /**
+ * Normalize a metadata value's language into a valid BCP 47 tag for the HTML
+ * `lang` attribute, or null when none applies.
+ * @param value A MetadataValue being displayed
+ */
+ getLang(value: MetadataValue): string | null {
+ return metadataLangToBcp47(value.language);
+ }
}
diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html
index 0a73c67e189..234a9cde68b 100644
--- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html
+++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html
@@ -28,7 +28,7 @@
@@ -69,7 +69,7 @@
diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.spec.ts b/src/app/item-page/full/field-components/file-section/full-file-section.component.spec.ts
index f8a8a83f294..d1caceab82d 100644
--- a/src/app/item-page/full/field-components/file-section/full-file-section.component.spec.ts
+++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.spec.ts
@@ -7,6 +7,7 @@ import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.m
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { VarDirective } from '../../../../shared/utils/var.directive';
import { FileSizePipe } from '../../../../shared/utils/file-size-pipe';
+import { DsLangPipe } from '../../../../shared/utils/ds-lang.pipe';
import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
@@ -67,7 +68,7 @@ describe('FullFileSectionComponent', () => {
useClass: TranslateLoaderMock
}
}), BrowserAnimationsModule],
- declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
+ declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent, DsLangPipe],
providers: [
{ provide: BitstreamDataService, useValue: bitstreamDataService },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
diff --git a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts
index f190c0770cd..ecc8b49ee13 100644
--- a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts
+++ b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts
@@ -1,6 +1,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
import { makeLinks } from '../../../../shared/clarin-shared-util';
+import { metadataLangToBcp47 } from '../../../../shared/utils/metadata-language.util';
@Component({
selector: 'ds-clarin-description-item-field',
@@ -27,7 +28,7 @@ export class ClarinDescriptionItemFieldComponent implements OnInit {
ngOnInit(): void {
this.descriptionEntries = this.item.allMetadata(this.fields).map(md => ({
value: makeLinks(md.value),
- language: md.language || null
+ language: metadataLangToBcp47(md.language)
}));
}
diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts
index 3002ffb5d41..79c7c2ae531 100644
--- a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts
+++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts
@@ -28,6 +28,7 @@ import { LicenseType } from '../../item-page/clarin-license-info/clarin-license-
import { ListableObject } from '../object-collection/shared/listable-object.model';
import { ItemSearchResult } from '../object-collection/shared/item-search-result.model';
import { getItemPageRoute } from '../../item-page/item-page-routing-paths';
+import { metadataLangToBcp47 } from '../utils/metadata-language.util';
import { FindListOptions } from '../../core/data/find-list-options.model';
import { ClarinDateService } from '../clarin-date.service';
import { AUTHOR_METADATA_FIELDS } from '../../core/shared/clarin/constants';
@@ -151,7 +152,7 @@ export class ClarinItemBoxViewComponent implements OnInit {
this.itemUri = getItemPageRoute(this.item);
const descMeta = this.item?.firstMetadata('dc.description');
this.itemDescription = descMeta?.value || null;
- this.itemDescriptionLang = descMeta?.language || null;
+ this.itemDescriptionLang = metadataLangToBcp47(descMeta?.language);
this.itemPublisher = this.item?.firstMetadataValue('dc.publisher');
this.itemDate = this.clarinDateService.composeItemDate(this.item);
diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts
index dc42b033d84..27d78ab3381 100644
--- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts
@@ -24,6 +24,7 @@ import { ItemSearchResult } from '../../../../object-collection/shared/item-sear
import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils';
import { TruncatableService } from '../../../../truncatable/truncatable.service';
import { TruncatePipe } from '../../../../utils/truncate.pipe';
+import { DsLangPipe } from '../../../../utils/ds-lang.pipe';
import { ItemSearchResultGridElementComponent } from './item-search-result-grid-element.component';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
@@ -218,7 +219,7 @@ export function getEntityGridElementTestComponent(component, searchResultWithMet
NoopAnimationsModule,
TranslateModule.forRoot()
],
- declarations: [component, TruncatePipe],
+ declarations: [component, TruncatePipe, DsLangPipe],
providers: [
{ provide: TruncatableService, useValue: truncatableServiceStub },
{ provide: ObjectCacheService, useValue: {} },
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index c1f77f288b2..d0ea3a1bd28 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -300,6 +300,7 @@ import { ReplacePipe } from './utils/replace.pipe';
import { ClarinDateService } from './clarin-date.service';
import { ItemIdentifierService } from './item-identifier.service';
import { ClarinLicenseRequiredInfoCheckedPipe } from './utils/clarin-license-required-info-checked.pipe';
+import { DsLangPipe } from './utils/ds-lang.pipe';
const MODULES = [
CommonModule,
@@ -347,7 +348,8 @@ const PIPES = [
ClarinLicenseRequiredInfoPipe,
CharToEndPipe,
ClarinSafeHtmlPipe,
- ReplacePipe
+ ReplacePipe,
+ DsLangPipe
];
const COMPONENTS = [
diff --git a/src/app/shared/utils/ds-lang.pipe.spec.ts b/src/app/shared/utils/ds-lang.pipe.spec.ts
new file mode 100644
index 00000000000..6ad408b6de1
--- /dev/null
+++ b/src/app/shared/utils/ds-lang.pipe.spec.ts
@@ -0,0 +1,25 @@
+import { DsLangPipe } from './ds-lang.pipe';
+
+describe('DsLangPipe', () => {
+ let pipe: DsLangPipe;
+
+ beforeEach(() => {
+ pipe = new DsLangPipe();
+ });
+
+ it('should create', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should normalize a Java-style locale to a BCP 47 tag', () => {
+ expect(pipe.transform('en_US')).toEqual('en-US');
+ expect(pipe.transform('cs_CZ')).toEqual('cs-CZ');
+ });
+
+ it('should return null for the wildcard, empty and nullish values', () => {
+ expect(pipe.transform('*')).toBeNull();
+ expect(pipe.transform('')).toBeNull();
+ expect(pipe.transform(null)).toBeNull();
+ expect(pipe.transform(undefined)).toBeNull();
+ });
+});
diff --git a/src/app/shared/utils/ds-lang.pipe.ts b/src/app/shared/utils/ds-lang.pipe.ts
new file mode 100644
index 00000000000..1af1e71f60a
--- /dev/null
+++ b/src/app/shared/utils/ds-lang.pipe.ts
@@ -0,0 +1,24 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { metadataLangToBcp47 } from './metadata-language.util';
+
+/**
+ * Pipe that normalizes a DSpace metadata language value into a valid BCP 47
+ * language tag for an HTML `lang` attribute, or `null` when none applies.
+ *
+ * Delegates to {@link metadataLangToBcp47}, so binding `[attr.lang]` through this
+ * pipe both omits the attribute for absent/wildcard languages and converts
+ * Java-style locales (`en_US` → `en-US`).
+ */
+@Pipe({
+ name: 'dsLang'
+})
+export class DsLangPipe implements PipeTransform {
+ /**
+ * @param {string} language the raw metadata language value
+ * @returns {string | null} a BCP 47 language tag, or null when none applies
+ */
+ transform(language: string | null | undefined): string | null {
+ return metadataLangToBcp47(language);
+ }
+}
diff --git a/src/app/shared/utils/metadata-language.util.spec.ts b/src/app/shared/utils/metadata-language.util.spec.ts
new file mode 100644
index 00000000000..cd793b8523b
--- /dev/null
+++ b/src/app/shared/utils/metadata-language.util.spec.ts
@@ -0,0 +1,30 @@
+import { metadataLangToBcp47 } from './metadata-language.util';
+
+describe('metadataLangToBcp47', () => {
+ it('should return null for nullish values', () => {
+ expect(metadataLangToBcp47(null)).toBeNull();
+ expect(metadataLangToBcp47(undefined)).toBeNull();
+ });
+
+ it('should return null for an empty string', () => {
+ expect(metadataLangToBcp47('')).toBeNull();
+ });
+
+ it('should return null for the wildcard language', () => {
+ expect(metadataLangToBcp47('*')).toBeNull();
+ });
+
+ it('should convert Java-style locale separators to BCP 47 hyphens', () => {
+ expect(metadataLangToBcp47('en_US')).toEqual('en-US');
+ expect(metadataLangToBcp47('cs_CZ')).toEqual('cs-CZ');
+ expect(metadataLangToBcp47('de_DE')).toEqual('de-DE');
+ });
+
+ it('should leave a bare language subtag unchanged', () => {
+ expect(metadataLangToBcp47('en')).toEqual('en');
+ });
+
+ it('should leave already-hyphenated tags unchanged', () => {
+ expect(metadataLangToBcp47('en-US')).toEqual('en-US');
+ });
+});
diff --git a/src/app/shared/utils/metadata-language.util.ts b/src/app/shared/utils/metadata-language.util.ts
new file mode 100644
index 00000000000..75b59fa1c96
--- /dev/null
+++ b/src/app/shared/utils/metadata-language.util.ts
@@ -0,0 +1,20 @@
+/**
+ * Convert a DSpace metadata language value into a valid BCP 47 language tag
+ * suitable for an HTML `lang` attribute, or `null` when none applies.
+ *
+ * DSpace stores the metadata language as a Java-style locale (e.g. `en_US`,
+ * `cs_CZ`), the wildcard `*` (language-independent), or nothing. None of the
+ * underscore forms or the wildcard are valid BCP 47 / HTML `lang` values, so
+ * assistive technology ignores them. This normalizes them:
+ * - `null`/`undefined`/empty and the wildcard `*` → `null` (attribute omitted)
+ * - the Java-style separator is converted: `en_US` → `en-US`, `cs_CZ` → `cs-CZ`
+ *
+ * @param language the raw metadata language value
+ * @returns a BCP 47 language tag, or `null` when no valid tag applies
+ */
+export function metadataLangToBcp47(language: string | null | undefined): string | null {
+ if (!language || language === '*') {
+ return null;
+ }
+ return language.replace(/_/g, '-');
+}