Skip to content

Commit 7d4cc8c

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 d929c2c commit 7d4cc8c

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',
@@ -119,6 +126,20 @@ describe('BitstreamDownloadPageComponent', () => {
119126
isMatomoScriptLoaded$: of(true),
120127
});
121128
matomoService.appendVisitorId.and.callFake((link) => of(link));
129+
linkHeadService = jasmine.createSpyObj('LinkHeadService', {
130+
addTag: {},
131+
removeTag: {},
132+
});
133+
appConfig = {
134+
seo: {
135+
canonical: {
136+
items: true,
137+
bitstreams: true,
138+
},
139+
},
140+
} as any;
141+
142+
(hardRedirectService as any).getCurrentOrigin = jasmine.createSpy('getCurrentOrigin').and.returnValue('https://example.org');
122143
}
123144

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

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
}
@@ -160,18 +168,34 @@ export class BitstreamDownloadPageComponent implements OnInit {
160168
* @private
161169
*/
162170
private initPageLinks(): void {
163-
if (isPlatformServer(this.platformId)) {
164-
this.route.params.subscribe(params => {
171+
this.route.params.subscribe(params => {
172+
if (this.appConfig.seo?.canonical?.bitstreams !== false) {
173+
const origin = this.hardRedirectService.getCurrentOrigin();
174+
const canonicalUrl = `${origin}/bitstreams/${params.id}/download`;
175+
this.linkHeadService.addTag({ rel: 'canonical', href: canonicalUrl });
176+
}
177+
178+
if (isPlatformServer(this.platformId)) {
165179
this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => {
166180
let links = '';
167181

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

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

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
@@ -33,6 +33,7 @@ import { INotificationBoardOptions } from './notifications-config.interfaces';
3333
import { QualityAssuranceConfig } from './quality-assurance.config';
3434
import { SearchConfig } from './search-page-config.interface';
3535
import { SearchResultConfig } from './search-result-config.interface';
36+
import { SeoConfig } from './seo-config.interface';
3637
import { ServerConfig } from './server-config.interface';
3738
import { SubmissionConfig } from './submission-config.interface';
3839
import { SuggestionConfig } from './suggestion-config.interfaces';
@@ -77,6 +78,7 @@ interface AppConfig extends Config {
7778
searchResult: SearchResultConfig;
7879
addToAnyPlugin: AddToAnyPluginConfig;
7980
cms: CmsMetadata;
81+
seo?: SeoConfig;
8082
}
8183

8284
/**

0 commit comments

Comments
 (0)