Skip to content

Commit 869a3cb

Browse files
bram-atmireclaude
andcommitted
Add canonical link tags for item pages and bitstream downloads
Adds <link rel="canonical"> to item page HTML heads and a Link HTTP header with rel="canonical" to bitstream download responses. This helps search engines identify the preferred URL for each page, preventing self-competition between /items/{uuid}, /entities/{type}/{uuid}, and /items/{uuid}/full routes. Both features are configurable via seo.canonical.items and seo.canonical.bitstreams settings (enabled by default). Refs #4509 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ddac164 commit 869a3cb

7 files changed

Lines changed: 237 additions & 6 deletions

File tree

src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ import {
1212
ActivatedRoute,
1313
Router,
1414
} from '@angular/router';
15+
import {
16+
APP_CONFIG,
17+
AppConfig,
18+
} from '@dspace/config/app-config.interface';
1519
import { AuthService } from '@dspace/core/auth/auth.service';
1620
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
1721
import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service';
1822
import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service';
1923
import { SignpostingDataService } from '@dspace/core/data/signposting-data.service';
2024
import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths';
2125
import { HardRedirectService } from '@dspace/core/services/hard-redirect.service';
26+
import { LinkHeadService } from '@dspace/core/services/link-head.service';
2227
import { ServerResponseService } from '@dspace/core/services/server-response.service';
2328
import { Bitstream } from '@dspace/core/shared/bitstream.model';
2429
import { FileService } from '@dspace/core/shared/file.service';
@@ -46,6 +51,8 @@ describe('BitstreamDownloadPageComponent', () => {
4651
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
4752
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
4853
let matomoService: jasmine.SpyObj<MatomoService>;
54+
let linkHeadService: jasmine.SpyObj<LinkHeadService>;
55+
let appConfig: AppConfig;
4956

5057
const mocklink = {
5158
href: 'http://test.org',
@@ -118,6 +125,20 @@ describe('BitstreamDownloadPageComponent', () => {
118125
isMatomoEnabled$: of(true),
119126
});
120127
matomoService.appendVisitorId.and.callFake((link) => of(link));
128+
linkHeadService = jasmine.createSpyObj('LinkHeadService', {
129+
addTag: {},
130+
removeTag: {},
131+
});
132+
appConfig = {
133+
seo: {
134+
canonical: {
135+
items: true,
136+
bitstreams: true,
137+
},
138+
},
139+
} as any;
140+
141+
(hardRedirectService as any).getCurrentOrigin = jasmine.createSpy('getCurrentOrigin').and.returnValue('https://example.org');
121142
}
122143

123144
function initTestbed() {
@@ -137,6 +158,8 @@ describe('BitstreamDownloadPageComponent', () => {
137158
{ provide: Location, useValue: location },
138159
{ provide: DSONameService, useValue: dsoNameService },
139160
{ provide: ConfigurationDataService, useValue: {} },
161+
{ provide: APP_CONFIG, useValue: appConfig },
162+
{ provide: LinkHeadService, useValue: linkHeadService },
140163
],
141164
})
142165
.compileComponents();
@@ -270,4 +293,51 @@ describe('BitstreamDownloadPageComponent', () => {
270293
});
271294
}));
272295
});
296+
297+
describe('canonical link', () => {
298+
describe('when seo.canonical.bitstreams is true', () => {
299+
beforeEach(waitForAsync(() => {
300+
init();
301+
initTestbed();
302+
}));
303+
beforeEach(() => {
304+
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
305+
component = fixture.componentInstance;
306+
fixture.detectChanges();
307+
});
308+
it('should add canonical link tag to the head', () => {
309+
expect(linkHeadService.addTag).toHaveBeenCalledWith({
310+
rel: 'canonical',
311+
href: 'https://example.org/bitstreams/testid/download',
312+
});
313+
});
314+
it('should include canonical in the Link header', () => {
315+
expect(serverResponseService.setHeader).toHaveBeenCalled();
316+
const linkHeaderValue = (serverResponseService.setHeader as jasmine.Spy).calls.mostRecent().args[1];
317+
expect(linkHeaderValue).toContain('rel="canonical"');
318+
expect(linkHeaderValue).toContain('https://example.org/bitstreams/testid/download');
319+
});
320+
it('should remove canonical link tag on destroy', () => {
321+
component.ngOnDestroy();
322+
expect(linkHeadService.removeTag).toHaveBeenCalledWith('rel="canonical"');
323+
});
324+
});
325+
326+
describe('when seo.canonical.bitstreams is false', () => {
327+
beforeEach(waitForAsync(() => {
328+
init();
329+
appConfig = { seo: { canonical: { items: true, bitstreams: false } } } as any;
330+
(hardRedirectService as any).getCurrentOrigin = jasmine.createSpy('getCurrentOrigin').and.returnValue('https://example.org');
331+
initTestbed();
332+
}));
333+
beforeEach(() => {
334+
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
335+
component = fixture.componentInstance;
336+
fixture.detectChanges();
337+
});
338+
it('should not add canonical link tag', () => {
339+
expect(linkHeadService.addTag).not.toHaveBeenCalled();
340+
});
341+
});
342+
});
273343
});

src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Component,
88
Inject,
99
inject,
10+
OnDestroy,
1011
OnInit,
1112
PLATFORM_ID,
1213
} from '@angular/core';
@@ -15,6 +16,10 @@ import {
1516
Params,
1617
Router,
1718
} from '@angular/router';
19+
import {
20+
APP_CONFIG,
21+
AppConfig,
22+
} from '@dspace/config/app-config.interface';
1823
import { AuthService } from '@dspace/core/auth/auth.service';
1924
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
2025
import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service';
@@ -25,6 +30,7 @@ import { SignpostingDataService } from '@dspace/core/data/signposting-data.servi
2530
import { SignpostingLink } from '@dspace/core/data/signposting-links.model';
2631
import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths';
2732
import { HardRedirectService } from '@dspace/core/services/hard-redirect.service';
33+
import { LinkHeadService } from '@dspace/core/services/link-head.service';
2834
import { ServerResponseService } from '@dspace/core/services/server-response.service';
2935
import { redirectOn4xx } from '@dspace/core/shared/authorized.operators';
3036
import { Bitstream } from '@dspace/core/shared/bitstream.model';
@@ -60,7 +66,7 @@ import { MatomoService } from '../../statistics/matomo.service';
6066
/**
6167
* Page component for downloading a bitstream
6268
*/
63-
export class BitstreamDownloadPageComponent implements OnInit {
69+
export class BitstreamDownloadPageComponent implements OnInit, OnDestroy {
6470

6571
bitstream$: Observable<Bitstream>;
6672
bitstreamRD$: Observable<RemoteData<Bitstream>>;
@@ -80,6 +86,8 @@ export class BitstreamDownloadPageComponent implements OnInit {
8086
private responseService: ServerResponseService,
8187
private matomoService: MatomoService,
8288
@Inject(PLATFORM_ID) protected platformId: string,
89+
@Inject(APP_CONFIG) private appConfig: AppConfig,
90+
private linkHeadService: LinkHeadService,
8391
) {
8492
this.initPageLinks();
8593
}
@@ -159,18 +167,34 @@ export class BitstreamDownloadPageComponent implements OnInit {
159167
* @private
160168
*/
161169
private initPageLinks(): void {
162-
if (isPlatformServer(this.platformId)) {
163-
this.route.params.subscribe(params => {
170+
this.route.params.subscribe(params => {
171+
if (this.appConfig.seo?.canonical?.bitstreams !== false) {
172+
const origin = this.hardRedirectService.getCurrentOrigin();
173+
const canonicalUrl = `${origin}/bitstreams/${params.id}/download`;
174+
this.linkHeadService.addTag({ rel: 'canonical', href: canonicalUrl });
175+
}
176+
177+
if (isPlatformServer(this.platformId)) {
164178
this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => {
165179
let links = '';
166180

167181
signpostingLinks.forEach((link: SignpostingLink) => {
168182
links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' ');
169183
});
170184

185+
if (this.appConfig.seo?.canonical?.bitstreams !== false) {
186+
const origin = this.hardRedirectService.getCurrentOrigin();
187+
const canonicalUrl = `${origin}/bitstreams/${params.id}/download`;
188+
links = links + (isNotEmpty(links) ? ', ' : '') + `<${canonicalUrl}> ; rel="canonical"`;
189+
}
190+
171191
this.responseService.setHeader('Link', links);
172192
});
173-
});
174-
}
193+
}
194+
});
195+
}
196+
197+
ngOnDestroy(): void {
198+
this.linkHeadService.removeTag('rel="canonical"');
175199
}
176200
}

src/app/core/metadata/head-tag.service.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import { PaginatedList } from '../data/paginated-list.model';
2424
import { RemoteData } from '../data/remote-data';
2525
import { RootDataService } from '../data/root-data.service';
2626
import { HardRedirectService } from '../services/hard-redirect.service';
27+
import { LinkHeadService } from '../services/link-head.service';
2728
import { Bitstream } from '../shared/bitstream.model';
2829
import { Bundle } from '../shared/bundle.model';
30+
import { DSpaceObject } from '../shared/dspace-object.model';
2931
import { Item } from '../shared/item.model';
3032
import { MetadataValue } from '../shared/metadata.models';
3133
import {
@@ -64,6 +66,7 @@ describe('HeadTagService', () => {
6466

6567
let router: Router;
6668
let store;
69+
let linkHeadService: LinkHeadService;
6770

6871
let appConfig: AppConfig;
6972

@@ -101,6 +104,10 @@ describe('HeadTagService', () => {
101104
authorizationService = jasmine.createSpyObj('authorizationService', {
102105
isAuthorized: of(true),
103106
});
107+
linkHeadService = jasmine.createSpyObj('linkHeadService', {
108+
addTag: {},
109+
removeTag: {},
110+
});
104111

105112
store = createMockStore({ initialState });
106113
spyOn(store, 'dispatch');
@@ -125,6 +132,7 @@ describe('HeadTagService', () => {
125132
hardRedirectService,
126133
appConfig,
127134
authorizationService,
135+
linkHeadService,
128136
);
129137
});
130138

@@ -493,6 +501,78 @@ describe('HeadTagService', () => {
493501
});
494502
});
495503

504+
describe('canonical link tag', () => {
505+
it('should add canonical link tag for Item DSO', fakeAsync(() => {
506+
(headTagService as any).processRouteChange({
507+
data: {
508+
value: {
509+
dso: createSuccessfulRemoteDataObject(ItemMock),
510+
},
511+
},
512+
});
513+
tick();
514+
expect(linkHeadService.addTag).toHaveBeenCalledWith({
515+
rel: 'canonical',
516+
href: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
517+
});
518+
}));
519+
520+
it('should remove canonical link tag on route change', fakeAsync(() => {
521+
(headTagService as any).processRouteChange({
522+
data: {
523+
value: {
524+
dso: createSuccessfulRemoteDataObject(ItemMock),
525+
},
526+
},
527+
});
528+
tick();
529+
expect(linkHeadService.removeTag).toHaveBeenCalledWith('rel="canonical"');
530+
}));
531+
532+
it('should not add canonical link tag when navigating to a non-DSO page', fakeAsync(() => {
533+
(translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'));
534+
(headTagService as any).processRouteChange({
535+
data: {
536+
value: {
537+
title: 'Dummy Title',
538+
},
539+
},
540+
});
541+
tick();
542+
expect(linkHeadService.addTag).not.toHaveBeenCalled();
543+
}));
544+
545+
it('should not add canonical link tag when seo.canonical.items is false', fakeAsync(() => {
546+
(appConfig as any).seo = { canonical: { items: false, bitstreams: true } };
547+
(headTagService as any).processRouteChange({
548+
data: {
549+
value: {
550+
dso: createSuccessfulRemoteDataObject(ItemMock),
551+
},
552+
},
553+
});
554+
tick();
555+
expect(linkHeadService.addTag).not.toHaveBeenCalled();
556+
}));
557+
558+
it('should not add canonical link tag for non-Item DSO', fakeAsync(() => {
559+
const nonItemDso = Object.assign(new DSpaceObject(), {
560+
uuid: 'some-uuid',
561+
metadata: {},
562+
});
563+
(dsoNameService.getName as jasmine.Spy).and.returnValue('Non-Item');
564+
(headTagService as any).processRouteChange({
565+
data: {
566+
value: {
567+
dso: createSuccessfulRemoteDataObject(nonItemDso),
568+
},
569+
},
570+
});
571+
tick();
572+
expect(linkHeadService.addTag).not.toHaveBeenCalled();
573+
}));
574+
});
575+
496576
const mockType = (mockItem: Item, type: string): Item => {
497577
const typedMockItem = Object.assign(new Item(), mockItem) as Item;
498578
typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[];

src/app/core/metadata/head-tag.service.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ import { FindListOptions } from '../data/find-list-options.model';
5252
import { PaginatedList } from '../data/paginated-list.model';
5353
import { RemoteData } from '../data/remote-data';
5454
import { RootDataService } from '../data/root-data.service';
55-
import { getBitstreamDownloadRoute } from '../router/utils/dso-route.utils';
55+
import {
56+
getBitstreamDownloadRoute,
57+
getItemPageRoute,
58+
} from '../router/utils/dso-route.utils';
5659
import { HardRedirectService } from '../services/hard-redirect.service';
60+
import { LinkHeadService } from '../services/link-head.service';
5761
import { Bitstream } from '../shared/bitstream.model';
5862
import { getDownloadableBitstream } from '../shared/bitstream.operators';
5963
import { BitstreamFormat } from '../shared/bitstream-format.model';
@@ -123,6 +127,7 @@ export class HeadTagService {
123127
protected hardRedirectService: HardRedirectService,
124128
@Inject(APP_CONFIG) protected appConfig: AppConfig,
125129
protected authorizationService: AuthorizationDataService,
130+
protected linkHeadService: LinkHeadService,
126131
) {
127132
}
128133

@@ -143,6 +148,7 @@ export class HeadTagService {
143148

144149
protected processRouteChange(routeInfo: any): void {
145150
this.clearMetaTags();
151+
this.linkHeadService.removeTag('rel="canonical"');
146152

147153
if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) {
148154
this.currentObject.next(routeInfo.data.value.dso.payload);
@@ -210,6 +216,7 @@ export class HeadTagService {
210216
// this.setCitationPatentCountryTag();
211217
// this.setCitationPatentNumberTag();
212218

219+
this.setCanonicalLinkTag();
213220
}
214221

215222
/**
@@ -221,6 +228,20 @@ export class HeadTagService {
221228
}
222229
}
223230

231+
/**
232+
* Add <link rel="canonical"> to the <head> for Item pages.
233+
* The canonical URL always points to the simple item view route.
234+
*/
235+
protected setCanonicalLinkTag(): void {
236+
if (this.appConfig.seo?.canonical?.items !== false
237+
&& this.currentObject.value instanceof Item) {
238+
const origin = this.hardRedirectService.getCurrentOrigin();
239+
const route = getItemPageRoute(this.currentObject.value as Item);
240+
const canonicalUrl = new URLCombiner(origin, route).toString();
241+
this.linkHeadService.addTag({ rel: 'canonical', href: canonicalUrl });
242+
}
243+
}
244+
224245
/**
225246
* Add <meta name="title" ... > to the <head>
226247
*/

src/config/app-config.interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { MediaViewerConfig } from './media-viewer-config.interface';
2929
import { INotificationBoardOptions } from './notifications-config.interfaces';
3030
import { QualityAssuranceConfig } from './quality-assurance.config';
3131
import { SearchConfig } from './search-page-config.interface';
32+
import { SeoConfig } from './seo-config.interface';
3233
import { ServerConfig } from './server-config.interface';
3334
import { SubmissionConfig } from './submission-config.interface';
3435
import { SuggestionConfig } from './suggestion-config.interfaces';
@@ -69,6 +70,7 @@ interface AppConfig extends Config {
6970
matomo?: MatomoConfig;
7071
geospatialMapViewer: GeospatialMapConfig;
7172
accessibility: AccessibilitySettingsConfig;
73+
seo?: SeoConfig;
7274
}
7375

7476
/**

0 commit comments

Comments
 (0)