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 @@ + [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 @@

{{"item.page.filesection.original.bund
{{"item.page.filesection.description" | translate}}
-
{{file.firstMetadataValue("dc.description")}}
+
{{file.firstMetadataValue("dc.description")}}

@@ -69,7 +69,7 @@

{{"item.page.filesection.license.bundl
{{"item.page.filesection.description" | translate}}
-
{{file.firstMetadataValue("dc.description")}}
+
{{file.firstMetadataValue("dc.description")}}

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, '-'); +}