Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/components/AbstractSources/__tests__/createUrlByType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest';
import { createUrlByType } from '@/components/AbstractSources/linkGenerator';

describe('createUrlByType', () => {
test('encodes # in a DOI identifier', () => {
const url = createUrlByType(
'1999AN....320..163M',
'doi',
'10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#',
);
expect(url).not.toContain('#');
expect(url).toContain('%23');
});

test('encodes < and > in a DOI identifier', () => {
const url = createUrlByType('test', 'doi', '10.1000/foo<bar>');
expect(url).toContain('%3C');
expect(url).toContain('%3E');
expect(url).not.toContain('<');
expect(url).not.toContain('>');
});

test('preserves / in DOI identifiers', () => {
const url = createUrlByType('test', 'doi', '10.48550/arXiv.2507.19320');
expect(url).toContain('10.48550/arXiv.2507.19320');
});

test('returns empty string for non-string arguments', () => {
expect(createUrlByType(null as unknown as string, 'doi', '10.1000/x')).toBe('');
expect(createUrlByType('bib', null as unknown as string, '10.1000/x')).toBe('');
expect(createUrlByType('bib', 'doi', null as unknown as string)).toBe('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('processLinkData', () => {
rawType: 'INSTITUTION',
shortName: 'My Institution',
type: 'INSTITUTION',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:article&rfr_id=info:sid/ADS&sid=ADS&id=doi:foo&genre=article&rft_id=info:doi/foo&rft.degree=false&rft.genre=article',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Aarticle&rfr_id=info%3Asid%2FADS&sid=ADS&id=doi%3Afoo&genre=article&rft_id=info%3Adoi%2Ffoo&rft.degree=false&rft.genre=article',
},
],
dataProducts: [],
Expand Down Expand Up @@ -163,7 +163,7 @@ describe('processLinkData', () => {
rawType: 'INSTITUTION',
shortName: 'My Institution',
type: 'INSTITUTION',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:article&rfr_id=info:sid/ADS&sid=ADS&id=doi:foo&genre=article&rft_id=info:doi/foo&rft_id=info:bibcode/test&rft.genre=article',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Aarticle&rfr_id=info%3Asid%2FADS&sid=ADS&id=doi%3Afoo&genre=article&rft_id=info%3Adoi%2Ffoo&rft_id=info%3Abibcode%2Ftest&rft.genre=article',
},
{
description: 'Publisher PDF',
Expand Down
13 changes: 13 additions & 0 deletions src/components/AbstractSources/__tests__/openUrlGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from 'vitest';
import { processLinkData } from '@/components/AbstractSources/linkGenerator';
import { getOpenUrl } from '@/components/AbstractSources/openUrlGenerator';

test('processLinkData produces correct output', () => {
expect(
Expand Down Expand Up @@ -151,3 +152,15 @@ test('processLinkData can handle empty input', () => {
),
).toEqual(defaultReturn);
});

test('encodes # in DOI when building OpenURL', () => {
const url = getOpenUrl({
metadata: {
doi: ['10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#'],
bibcode: '1999AN....320..163M',
},
linkServer: 'https://example.com/openurl',
});
expect(url).not.toContain('#');
expect(url).toContain('%23');
});
3 changes: 2 additions & 1 deletion src/components/AbstractSources/linkGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getOpenUrl } from './openUrlGenerator';
import { isNilOrEmpty, isNonEmptyString } from 'ramda-adjunct';
import { IDataProductSource, IFullTextSource, ProcessLinkDataReturns } from '@/components/AbstractSources/types';
import { Esources, IDocsEntity } from '@/api/search/types';
import { encodeDOIPath } from '@/utils/common/encodeDOI';

/**
* Create the resolver url
Expand Down Expand Up @@ -139,7 +140,7 @@ export const processLinkData = (doc: IDocsEntity, linkServer?: string): ProcessL
*/
export const createUrlByType = function (bibcode: string, type: string, identifier: string): string {
if (typeof bibcode === 'string' && typeof type === 'string' && typeof identifier === 'string') {
return `${GATEWAY_BASE_URL + bibcode}/${type}:${identifier}`;
return `${GATEWAY_BASE_URL + bibcode}/${type}:${encodeDOIPath(identifier)}`;
}
return '';
};
Expand Down
24 changes: 12 additions & 12 deletions src/components/AbstractSources/openUrlGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,15 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => {
};

interface IContext extends Partial<typeof parsed> {
spage: typeof parsed['rft.spage'];
volume: typeof parsed['rft.volume'];
title: typeof parsed['rft.jtitle'];
atitle: typeof parsed['rft.atitle'];
aulast: typeof parsed['rft.aulast'];
aufirst: typeof parsed['rft.aufirst'];
date: typeof parsed['rft.date'];
isbn: typeof parsed['rft.isbn'];
issn: typeof parsed['rft.issn'];
spage: (typeof parsed)['rft.spage'];
volume: (typeof parsed)['rft.volume'];
title: (typeof parsed)['rft.jtitle'];
atitle: (typeof parsed)['rft.atitle'];
aulast: (typeof parsed)['rft.aulast'];
aufirst: (typeof parsed)['rft.aufirst'];
date: (typeof parsed)['rft.date'];
isbn: (typeof parsed)['rft.isbn'];
issn: (typeof parsed)['rft.issn'];
}

// add extra fields to context object
Expand Down Expand Up @@ -110,11 +110,11 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => {
if (isArray(val)) {
return val
.filter((v) => v)
.map((v) => `${k}=${v}`)
.map((v) => `${k}=${encodeURIComponent(String(v))}`)
.join('&');
}
return `${k}=${val}`;
return `${k}=${encodeURIComponent(String(val))}`;
});

return encodeURI(openUrl + fields.join('&'));
return openUrl + fields.join('&');
};
15 changes: 15 additions & 0 deletions src/components/Layout/AbsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ interface IAbsLayoutProps {
export const AbsLayout: FC<IAbsLayoutProps> = ({ children, doc, titleDescription, label }) => {
const rawTitle = doc ? unwrapStringValue(doc.title) : '';

useEffect(() => {
// After a server-side redirect from a legacy DOI URL whose '#' was stripped by the
// browser as a fragment, the browser re-attaches the original fragment to the redirect
// destination (RFC 9110). Strip it when it's just a redundant view name.
const hash = window.location.hash;
if (
hash &&
/^#\/?(?:abstract|citations|references|credits|mentions|coreads|similar|graphics|metrics|toc|exportcitation)/.test(
hash,
)
) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}, []);

useEffect(() => {
if (!doc?.bibcode) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, test } from 'vitest';
import { collectIdentifiersFromArray } from '../identifiers';

describe('collectIdentifiersFromArray', () => {
it('parses common IDs from identifier[] only', () => {
test('parses common IDs from identifier[] only', () => {
const { identifiers, sameAs } = collectIdentifiersFromArray({
identifier: [
'arXiv:2503.12263',
Expand Down Expand Up @@ -40,15 +40,15 @@ describe('collectIdentifiersFromArray', () => {
expect(sameAs).toContain('https://www.semanticscholar.org/paper/abcdef');
});

it('ignores junk without throwing', () => {
test('ignores junk without throwing', () => {
const { identifiers, sameAs } = collectIdentifiersFromArray({
identifier: ['', ' ', 'not-an-id', 0 as unknown as string],
});
expect(Array.isArray(identifiers)).toBe(true);
expect(Array.isArray(sameAs)).toBe(true);
});

it('dedupes duplicate identifiers and trims spaces/tags', () => {
test('dedupes duplicate identifiers and trims spaces/tags', () => {
const { identifiers, sameAs } = collectIdentifiersFromArray({
identifier: [
'arXiv:2503.12263',
Expand All @@ -73,4 +73,16 @@ describe('collectIdentifiersFromArray', () => {
expect(sa.has('https://hdl.handle.net/1234/abc')).toBe(true);
expect(sa.size).toBe(3);
});

test('encodes special characters in DOI sameAs URL', () => {
const { sameAs } = collectIdentifiersFromArray({
identifier: ['10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#'],
});
const doiLink = sameAs.find((u) => u.startsWith('https://doi.org/'));
expect(doiLink).toBeDefined();
expect(doiLink).not.toContain('#');
expect(doiLink).toContain('%23');
expect(doiLink).not.toContain('<');
expect(doiLink).toContain('%3C');
});
});
3 changes: 2 additions & 1 deletion src/components/Metatags/json-ld-abstract/identifiers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PropertyValue } from 'schema-dts';
import { encodeDOIPath } from '@/utils/common/encodeDOI';

/**
* Minimal structure containing only identifiers we parse.
Expand Down Expand Up @@ -68,7 +69,7 @@ function buildSameAs(pvs: PropertyValue[]) {
const v = String(value);
switch (propertyID) {
case 'DOI':
out.add(`https://doi.org/${v}`);
out.add(`https://doi.org/${encodeDOIPath(v)}`);
break;
case 'arXiv':
out.add(`https://arxiv.org/abs/${v}`);
Expand Down
61 changes: 55 additions & 6 deletions src/lib/serverside/__tests__/absCanonicalization.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, beforeAll, afterAll, vi } from 'vitest';
import { describe, expect, test, beforeEach, beforeAll, afterAll, vi } from 'vitest';
import type { GetServerSidePropsContext } from 'next';

import { createAbsGetServerSideProps } from '../absCanonicalization';
Expand Down Expand Up @@ -77,7 +77,7 @@ beforeEach(() => {
});

describe('createAbsGetServerSideProps', () => {
it('redirects to canonical bibcode with encoding and preserves query', async () => {
test('redirects to canonical bibcode with encoding and preserves query', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
Expand All @@ -100,7 +100,7 @@ describe('createAbsGetServerSideProps', () => {
}
});

it('redirects for other views', async () => {
test('redirects for other views', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
Expand All @@ -123,7 +123,7 @@ describe('createAbsGetServerSideProps', () => {
}
});

it('returns props when identifier is already canonical', async () => {
test('returns props when identifier is already canonical', async () => {
const bibcode = 'MATCHING';
fetchMock.mockResolvedValue({
ok: true,
Expand All @@ -150,7 +150,7 @@ describe('createAbsGetServerSideProps', () => {
);
});

it('forwards tracing headers to the search API', async () => {
test('forwards tracing headers to the search API', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
Expand Down Expand Up @@ -182,7 +182,7 @@ describe('createAbsGetServerSideProps', () => {
);
});

it('does not redirect when no docs are returned', async () => {
test('does not redirect when no docs are returned', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
Expand All @@ -201,4 +201,53 @@ describe('createAbsGetServerSideProps', () => {
expect(result).not.toHaveProperty('redirect');
expect(result).toHaveProperty('props');
});

test('retries with # appended for DOIs when not found and redirects to canonical bibcode', async () => {
// DOIs like 10.1002/...3.0.CO;2-# end with '#', which browsers strip as a URL
// fragment when the character is not percent-encoded. The fallback detects this
// by retrying with '#' appended (DOIs only) and redirecting to the canonical bibcode URL.
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ response: { docs: [] } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ response: { docs: [{ bibcode: '1999AN....320..163H' }] } }),
});

const ctx = buildCtx({
id: '10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-',
resolvedUrl: '/abs/10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-/abstract',
});

const gssp = createAbsGetServerSideProps('abstract');
const result = await gssp(ctx);

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result).toHaveProperty('redirect');
if ('redirect' in result) {
expect(result.redirect?.destination).toBe('/abs/1999AN....320..163H/abstract');
expect(result.redirect?.statusCode).toBe(302);
}
});

test('does not retry with # for non-DOI identifiers', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ response: { docs: [] } }),
});

const ctx = buildCtx({
id: '2024ApJ...123..456X',
resolvedUrl: '/abs/2024ApJ...123..456X/abstract',
});

const gssp = createAbsGetServerSideProps('abstract');
const result = await gssp(ctx);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result).not.toHaveProperty('redirect');
expect(result).toHaveProperty('props');
});
});
41 changes: 41 additions & 0 deletions src/lib/serverside/absCanonicalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,47 @@ const absCanonicalize = (viewPathResolver: ViewPathResolver): IncomingGSSP => {
ctx.res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300');

const initialDoc = data?.response?.docs?.[0] ?? null;

// Some Wiley DOIs end with '#', which browsers strip as a URL fragment when the
// character is not percent-encoded. Retry once with '#' appended and redirect to
// the canonical bibcode URL so the page always loads correctly.
// Scoped to DOIs only (10.NNNN/ prefix) to avoid the extra round-trip for
// bibcodes, arXiv IDs, and other identifiers that can never end with '#'.
const isDoi = /^10\.\d{4,}\//.test(requestedId);
if (!initialDoc && isDoi && !requestedId.endsWith('#')) {
try {
const retryId = requestedId + '#';
const retryUrl = new URL(`${process.env.API_HOST_SERVER}${ApiTargets.SEARCH}`);
retryUrl.search = stringifySearchParams(getAbstractParams(retryId));
const retryResponse = await fetch(retryUrl, {
headers: {
Authorization: `Bearer ${bootstrapResult.token.access_token}`,
...tracingHeaders,
},
});
if (retryResponse.ok) {
const retryData = (await retryResponse.json()) as IADSApiSearchResponse;
const retryDoc = retryData?.response?.docs?.[0] ?? null;
if (retryDoc?.bibcode) {
const requestUrl = new URL(ctx.req.url ?? ctx.resolvedUrl, 'http://adsabs.local');
log.info({ requestedId, retryId, bibcode: retryDoc.bibcode, viewPath }, 'Hash fallback redirect');
return {
redirect: {
destination: buildRedirect({
canonicalIdentifier: retryDoc.bibcode,
viewPath,
search: requestUrl.search,
}),
statusCode: 302,
},
};
}
}
} catch (retryError) {
log.warn({ err: retryError, requestedId }, 'Hash fallback retry failed');
}
}

const canonicalIdentifier = initialDoc?.bibcode;

if (canonicalIdentifier && canonicalIdentifier !== requestedId) {
Expand Down
Loading
Loading