Skip to content

Commit 36056a8

Browse files
milanmajchrakclaude
andcommitted
ZCU-PUB/Add a configurable noindex meta tag for item pages
Adds item.noIndex - a list of item uuids or handles whose item page gets <meta name="robots" content="noindex, noarchive"> and has its citation_pdf_url dropped. Non-discoverable items get the tag too. Server-side rendered, so crawlers see the tag in the first HTML response. Emitted via addMetaTag() so clearMetaTags() removes it on the next route change instead of leaking it onto every page visited afterwards. No nofollow: crawlers should keep following bitstream links to pick up the X-Robots-Tag served for the files. Ships with an empty list on purpose - this config is transferred to the browser unsanitized, so entries would be publicly readable in every page's HTML. Guards on Array.isArray() because a misconfigured scalar would otherwise throw from the first statement of setDSOMetaTags() and strip the meta tags off every page. Tests: metadata.service.spec.ts 31 SUCCESS (19 existing + 12 new); reverting the production change turns 6 of them red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c455695 commit 36056a8

8 files changed

Lines changed: 158 additions & 1 deletion

File tree

config/config.example.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ item:
286286
undoTimeout: 10000 # 10 seconds
287287
# Show the item access status label in items lists
288288
showAccessStatuses: false
289+
# Item uuids or handles to exclude from search engine indexes. Empty = off.
290+
noIndex: []
289291
bitstream:
290292
# Number of entries in the bitstream list in the item view page.
291293
# Rounded to the nearest size in the list of selectable sizes on the

config/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,6 @@ item:
192192
bitstream:
193193
# Per-bitstream embargo-date badge in the Item View
194194
showAccessStatuses: true
195+
# Item uuids or handles to exclude from search engine indexes. Empty = off.
196+
# Keep empty: this config is served to the browser, so entries here are public.
197+
noIndex: []

src/app/core/metadata/metadata.service.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212
ItemMock,
1313
MockBitstream1,
1414
MockBitstream3,
15-
MockBitstream2
15+
MockBitstream2,
16+
NonDiscoverableItemMock
1617
} from '../../shared/mocks/item.mock';
18+
import { DSpaceObject } from '../shared/dspace-object.model';
1719
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
1820
import { PaginatedList } from '../data/paginated-list.model';
1921
import { Bitstream } from '../shared/bitstream.model';
@@ -96,6 +98,7 @@ describe('MetadataService', () => {
9698

9799
appConfig = {
98100
item: {
101+
noIndex: [],
99102
bitstream: {
100103
pageSize: 5
101104
}
@@ -406,6 +409,90 @@ describe('MetadataService', () => {
406409
});
407410
});
408411

412+
describe('robots meta tag', () => {
413+
const noIndexTag = { name: 'robots', content: 'noindex, noarchive' };
414+
415+
const routeTo = (dso: any) => {
416+
(metadataService as any).processRouteChange({
417+
data: { value: { dso: createSuccessfulRemoteDataObject(dso) } }
418+
});
419+
tick();
420+
};
421+
422+
it('should not add a robots tag for a normal discoverable item', fakeAsync(() => {
423+
routeTo(ItemMock);
424+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
425+
}));
426+
427+
it('should add a robots noindex tag for a non-discoverable item', fakeAsync(() => {
428+
routeTo(NonDiscoverableItemMock);
429+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
430+
}));
431+
432+
it('should add a robots noindex tag when the item uuid is configured', fakeAsync(() => {
433+
appConfig.item.noIndex = ['0ec7ff22-f211-40ab-a69e-c819b0b1f357'];
434+
routeTo(ItemMock);
435+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
436+
}));
437+
438+
it('should add a robots noindex tag when the item handle is configured', fakeAsync(() => {
439+
appConfig.item.noIndex = ['10673/6'];
440+
routeTo(ItemMock);
441+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
442+
}));
443+
444+
it('should normalize handle URLs, casing and whitespace in item.noIndex', fakeAsync(() => {
445+
appConfig.item.noIndex = [' HTTP://hdl.handle.net/10673/6 '];
446+
routeTo(ItemMock);
447+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
448+
}));
449+
450+
it('should not add a robots tag for an item that is not configured', fakeAsync(() => {
451+
appConfig.item.noIndex = ['11025/9501', 'f4c45569-cdfc-4b3d-98df-46bfeba016b9'];
452+
routeTo(ItemMock);
453+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
454+
}));
455+
456+
it('should not add a robots tag for a non-Item DSpaceObject', fakeAsync(() => {
457+
appConfig.item.noIndex = ['10673/6'];
458+
routeTo(Object.assign(new DSpaceObject(), { uuid: '10673/6', handle: '10673/6', metadata: {} }));
459+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
460+
}));
461+
462+
it('should register the robots tag in the meta tag store so it is cleared on the next route change', fakeAsync(() => {
463+
appConfig.item.noIndex = ['10673/6'];
464+
routeTo(ItemMock);
465+
expect(store.dispatch).toHaveBeenCalledWith(new AddMetaTagAction('robots'));
466+
}));
467+
468+
it('should suppress citation_pdf_url for a noindex item', fakeAsync(() => {
469+
appConfig.item.noIndex = ['10673/6'];
470+
routeTo(ItemMock);
471+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' }));
472+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_title' }));
473+
}));
474+
475+
it('should keep citation_pdf_url for a normal item', fakeAsync(() => {
476+
routeTo(ItemMock);
477+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' }));
478+
}));
479+
480+
it('should not break the other meta tags when item.noIndex is a scalar instead of a list', fakeAsync(() => {
481+
// Must degrade to "off", never throw - that would strip the meta tags off every page.
482+
appConfig.item.noIndex = '10673/6' as any;
483+
expect(() => routeTo(ItemMock)).not.toThrow();
484+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_title' }));
485+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
486+
}));
487+
488+
it('should ignore non-string entries in item.noIndex without throwing', fakeAsync(() => {
489+
appConfig.item.noIndex = [9501 as any, null, '10673/6'];
490+
expect(() => routeTo(ItemMock)).not.toThrow();
491+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
492+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_title' }));
493+
}));
494+
});
495+
409496
describe(`when there's no bitstream with an allowed format on the first page`, () => {
410497
let bitstreams;
411498

src/app/core/metadata/metadata.service.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ const tagsInUseSelector =
6363
(state: MetaTagState) => state.tagsInUse,
6464
);
6565

66+
// No `nofollow`: crawlers should still follow the bitstream links to pick up their own noindex.
67+
export const NO_INDEX_META_CONTENT = 'noindex, noarchive';
68+
6669
@Injectable()
6770
export class MetadataService {
6871

@@ -147,6 +150,8 @@ export class MetadataService {
147150

148151
private setDSOMetaTags(): void {
149152

153+
this.setNoIndexTag();
154+
150155
this.setTitleTag();
151156
this.setDescriptionTag();
152157

@@ -194,6 +199,46 @@ export class MetadataService {
194199

195200
}
196201

202+
/**
203+
* Add <meta name="robots"> for Items that must not be indexed by search engines.
204+
*
205+
* Uses addMetaTag() so the tag is registered in the meta tag store and cleared on the next route
206+
* change; this.meta.addTag() would leak it onto every page visited afterwards.
207+
*/
208+
protected setNoIndexTag(): void {
209+
if (this.isNoIndex()) {
210+
this.addMetaTag('robots', NO_INDEX_META_CONTENT);
211+
}
212+
}
213+
214+
private isNoIndex(): boolean {
215+
if (!(this.currentObject.value instanceof Item)) {
216+
return false;
217+
}
218+
const item = this.currentObject.value as Item;
219+
if (item.isDiscoverable === false) {
220+
return true;
221+
}
222+
// Array.isArray, not hasNoValue: a misconfigured scalar also has a length, and throwing here
223+
// would strip the meta tags off every page.
224+
const configured = this.appConfig?.item?.noIndex;
225+
if (!Array.isArray(configured) || configured.length === 0) {
226+
return false;
227+
}
228+
const itemIds = [item.uuid, item.handle]
229+
.filter((id) => isNotEmpty(id))
230+
.map((id) => this.normalizeNoIndexId(id));
231+
return configured.some((id: any) => typeof id === 'string' && isNotEmpty(id)
232+
&& itemIds.includes(this.normalizeNoIndexId(id)));
233+
}
234+
235+
// Accepts a bare handle, a hdl.handle.net URL or a uuid, in any casing.
236+
private normalizeNoIndexId(id: string): string {
237+
return id.trim().toLowerCase()
238+
.replace(/^https?:\/\/hdl\.handle\.net\//, '')
239+
.replace(/^\/+/, '');
240+
}
241+
197242
/**
198243
* Add <meta name="title" ... > to the <head>
199244
*/
@@ -349,6 +394,10 @@ export class MetadataService {
349394
* Add <meta name="citation_pdf_url" ... > to the <head>
350395
*/
351396
private setCitationPdfUrlTag(): void {
397+
// Google Scholar keys off this tag and does not reliably honour the landing page robots tag.
398+
if (this.isNoIndex()) {
399+
return;
400+
}
352401
if (this.currentObject.value instanceof Item) {
353402
const item = this.currentObject.value as Item;
354403

src/app/shared/mocks/item.mock.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,12 @@ export const ItemMock: Item = Object.assign(new Item(), {
294294
)
295295
});
296296
/* eslint-enable @typescript-eslint/no-shadow */
297+
298+
// `metadata` is copied because spec helpers mutate it in place and would hit ItemMock too.
299+
export const NonDiscoverableItemMock: Item = Object.assign(new Item(), ItemMock, {
300+
handle: '10673/7',
301+
id: '0ec7ff22-f211-40ab-a69e-c819b0b1f358',
302+
uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f358',
303+
isDiscoverable: false,
304+
metadata: Object.assign({}, ItemMock.metadata),
305+
});

src/config/default-app-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ export class DefaultAppConfig implements AppConfig {
281281
},
282282
// Show the item access status label in items lists
283283
showAccessStatuses: false,
284+
// Item uuids or handles to exclude from search engine indexes
285+
noIndex: [],
284286
bitstream: {
285287
// Number of entries in the bitstream list in the item view page.
286288
// Rounded to the nearest size in the list of selectable sizes on the

src/config/item-config.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export interface ItemConfig extends Config {
77
// This is used to show the access status label of items in results lists
88
showAccessStatuses: boolean;
99

10+
// Item uuids or handles to exclude from search engine indexes. Empty = off.
11+
noIndex: string[];
12+
1013
bitstream: {
1114
// Number of entries in the bitstream list in the item view page.
1215
// Rounded to the nearest size in the list of selectable sizes on the

src/environments/environment.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ export const environment: BuildConfig = {
250250
},
251251
// Show the item access status label in items lists
252252
showAccessStatuses: false,
253+
// Items excluded from search engine indexes (uuids or handles)
254+
noIndex: [],
253255
bitstream: {
254256
// Number of entries in the bitstream list in the item view page.
255257
// Rounded to the nearest size in the list of selectable sizes on the

0 commit comments

Comments
 (0)