diff --git a/core/ingest/fetch.test.ts b/core/ingest/fetch.test.ts index 2c8c544..b0d127f 100644 --- a/core/ingest/fetch.test.ts +++ b/core/ingest/fetch.test.ts @@ -31,6 +31,19 @@ const CROSSREF_FIXTURE = { }, } +const CROSSREF_FIXTURE_WITH_ORCIDS = { + message: { + title: ['ORCID-Bearing Paper'], + author: [ + { given: 'Ada', family: 'Lovelace', ORCID: 'http://orcid.org/0000-0002-1825-0097' }, + { given: 'Alan', family: 'Turing' }, + { given: 'Grace', family: 'Hopper', ORCID: 'https://orcid.org/0000-0001-5109-3700/' }, + ], + published: { 'date-parts': [[2023, 1, 1]] }, + 'container-title': ['Journal of Reproducible Authorship'], + }, +} + describe('fetchSource', () => { let fetchMock: ReturnType @@ -67,6 +80,7 @@ describe('fetchSource', () => { venue: 'NeurIPS 2024', abstract: 'This paper revisits attention mechanisms.', }) + expect(result.metadata?.authorOrcids).toBeUndefined() const calledUrls = fetchMock.mock.calls.map((c) => String(c[0])) expect(calledUrls.some((u) => u.includes('id_list=2401.12345'))).toBe(true) @@ -103,6 +117,7 @@ describe('fetchSource', () => { venue: 'Journal of Made-Up Science', abstract: 'A survey of things.', }) + expect(result.metadata?.authorOrcids).toBeUndefined() const calledUrls = fetchMock.mock.calls.map((c) => String(c[0])) expect( @@ -110,6 +125,37 @@ describe('fetchSource', () => { ).toBe(true) }) + it('extracts and bare-normalizes ORCIDs from Crossref, positionally aligned with nulls for authors with none', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('api.crossref.org/works/')) { + return Promise.resolve( + new Response(JSON.stringify(CROSSREF_FIXTURE_WITH_ORCIDS), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url.startsWith('https://doi.org/')) { + return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 })) + } + throw new Error(`unexpected fetch: ${url}`) + }) + + const input: ClassifiedInput = { + kind: 'doi', + slug: 'doi-orcid-test', + value: '10.1/orcid-test', + } + const result = await fetchSource(input) + + expect(result.metadata?.authors).toEqual(['Ada Lovelace', 'Alan Turing', 'Grace Hopper']) + expect(result.metadata?.authorOrcids).toEqual([ + '0000-0002-1825-0097', + null, + '0000-0001-5109-3700', + ]) + }) + it('fetches raw PDF bytes for a direct pdf_url with no metadata', async () => { fetchMock.mockImplementation((url: string) => { if (url === 'https://example.com/paper.pdf') { diff --git a/core/ingest/fetch.ts b/core/ingest/fetch.ts index 7222adf..f26140f 100644 --- a/core/ingest/fetch.ts +++ b/core/ingest/fetch.ts @@ -40,6 +40,10 @@ export interface RawMetadata { year: number | undefined venue: string | undefined abstract: string | undefined + /** Bare ORCID ids (e.g. "0000-0002-1825-0097"), positionally aligned to + * `authors`; null for authors with no known ORCID. Crossref-only signal — + * undefined when the source (e.g. arXiv) never carries ORCIDs. */ + authorOrcids?: (string | null)[] } export interface FetchedSource { @@ -115,7 +119,7 @@ async function fetchArxivPdf(arxivId: string): Promise { interface CrossrefWork { message: { title?: string[] - author?: Array<{ given?: string; family?: string; name?: string }> + author?: Array<{ given?: string; family?: string; name?: string; ORCID?: unknown }> published?: { 'date-parts'?: number[][] } 'published-print'?: { 'date-parts'?: number[][] } 'container-title'?: string[] @@ -123,6 +127,16 @@ interface CrossrefWork { } } +// Crossref's ORCID field is a URL like "http://orcid.org/0000-0002-1825-0097" +// or "https://orcid.org/0000-0002-1825-0097/" — normalize to the bare id. +// Defensive: any non-string or unrecognized shape yields null rather than +// throwing (ORCID is a best-effort signal, never load-bearing for ingest). +function normalizeOrcid(value: unknown): string | null { + if (typeof value !== 'string') return null + const stripped = value.trim().replace(/^https?:\/\/orcid\.org\//i, '').replace(/\/+$/, '') + return /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/.test(stripped) ? stripped : null +} + async function fetchCrossrefMetadata(doi: string): Promise { const res = await fetch(`${CROSSREF_API}/${encodeURIComponent(doi)}`) if (!res.ok) { @@ -136,9 +150,12 @@ async function fetchCrossrefMetadata(doi: string): Promise { throw new Error(`Crossref API: missing title for ${doi}`) } - const authors = (work.author ?? []).map((a) => + const crossrefAuthors = work.author ?? [] + const authors = crossrefAuthors.map((a) => a.name ?? [a.given, a.family].filter(Boolean).join(' '), ) + const authorOrcids = crossrefAuthors.map((a) => normalizeOrcid(a.ORCID)) + const hasAnyOrcid = authorOrcids.some((id) => id !== null) const dateParts = work.published?.['date-parts']?.[0] ?? work['published-print']?.['date-parts']?.[0] @@ -152,6 +169,7 @@ async function fetchCrossrefMetadata(doi: string): Promise { year, venue, abstract: work.abstract ? normalizeWhitespace(stripJats(work.abstract)) : undefined, + authorOrcids: hasAnyOrcid ? authorOrcids : undefined, } } diff --git a/core/ingest/index.test.ts b/core/ingest/index.test.ts index 337d774..c72e846 100644 --- a/core/ingest/index.test.ts +++ b/core/ingest/index.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AcpClient, AcpPromptRequest, AcpSession, AcpUpdate } from '../acp/client.js' import { getPaper, listPapers } from '../library/repo.js' @@ -130,6 +130,110 @@ describe('ingest', () => { } }) + // Shared Crossref fixture for the authorOrcids alignment tests below. + const CROSSREF_ORCID_BODY = JSON.stringify({ + message: { + title: ['Crossref Title'], + author: [ + { given: 'Ada', family: 'Lovelace', ORCID: 'http://orcid.org/0000-0002-1825-0097' }, + { given: 'Alan', family: 'Turing' }, + ], + published: { 'date-parts': [[2022, 1, 1]] }, + }, + }) + + function stubCrossrefFetch(pdfBytes: Buffer) { + const fetchMock = vi.fn((url: string) => { + if (url.includes('api.crossref.org/works/')) { + return Promise.resolve( + new Response(CROSSREF_ORCID_BODY, { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url.startsWith('https://doi.org/')) { + return Promise.resolve(new Response(new Uint8Array(pdfBytes), { status: 200 })) + } + throw new Error(`unexpected fetch: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + } + + it('drops authorOrcids when agent-extracted authors diverge from the Crossref list they were aligned to', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) + const db = openDb({ path: ':memory:' }) + // AGENT_JSON's authors (['Agent Author']) differ from the Crossref + // fixture's authors (['Ada Lovelace', 'Alan Turing']) — agent authors win + // per the extracted-first merge, so the Crossref-aligned ORCIDs would + // misalign if kept. + const client = new FakeClient([textUpdate(AGENT_JSON), doneUpdate]) + stubCrossrefFetch(readFileSync(FIXTURE_PDF)) + + try { + const result = await ingest('10.1234/orcid-divergent-test', { db, dataDir: tmpDir, client }) + + expect(result.metadata.authors).toEqual(['Agent Author']) + expect(result.metadata.authorOrcids).toBeUndefined() + + const row = getPaper(db, result.slug) + expect(row?.authorOrcids).toBeUndefined() + } finally { + db.close() + vi.unstubAllGlobals() + } + }) + + it('retains authorOrcids when agent-extracted authors are content-equal to the Crossref list', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) + const db = openDb({ path: ':memory:' }) + const agentJsonMatchingCrossref = JSON.stringify({ + sections: [{ title: 'Sample Paper Title', startOffset: 0, endOffset: 20 }], + metadata: { + title: 'Sample Paper Title (agent-refined)', + authors: ['Ada Lovelace', 'Alan Turing'], + year: 2026, + }, + }) + const client = new FakeClient([textUpdate(agentJsonMatchingCrossref), doneUpdate]) + stubCrossrefFetch(readFileSync(FIXTURE_PDF)) + + try { + const result = await ingest('10.1234/orcid-aligned-test', { db, dataDir: tmpDir, client }) + + expect(result.metadata.authors).toEqual(['Ada Lovelace', 'Alan Turing']) + expect(result.metadata.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + + const row = getPaper(db, result.slug) + expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + } finally { + db.close() + vi.unstubAllGlobals() + } + }) + + it('retains authorOrcids when the agent extracts no authors (falls back to the Crossref-aligned list)', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) + const db = openDb({ path: ':memory:' }) + // Agent returns nothing usable -> extractPaper degrades to {}, so + // metadata.authors falls back to fetched.metadata.authors verbatim. + const client = new FakeClient([textUpdate('no json here'), doneUpdate]) + stubCrossrefFetch(readFileSync(FIXTURE_PDF)) + + try { + const result = await ingest('10.1234/orcid-fallback-test', { db, dataDir: tmpDir, client }) + + expect(result.metadata.authors).toEqual(['Ada Lovelace', 'Alan Turing']) + expect(result.metadata.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + + const row = getPaper(db, result.slug) + expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + } finally { + db.close() + vi.unstubAllGlobals() + } + }) + it('re-ingesting the same input is idempotent: no duplicate row, files overwritten not duplicated', async () => { tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) const db = openDb({ path: ':memory:' }) diff --git a/core/ingest/index.ts b/core/ingest/index.ts index 5cdba23..ebccf5a 100644 --- a/core/ingest/index.ts +++ b/core/ingest/index.ts @@ -49,6 +49,9 @@ export interface IngestMetadata { year?: number venue?: string abstract?: string + /** Bare ORCID ids positionally aligned to `authors`; fetch-only signal + * (Crossref), never agent-extracted. Undefined when the source has none. */ + authorOrcids?: (string | null)[] } export interface IngestResult { @@ -107,12 +110,31 @@ export async function ingest(raw: string, options: IngestOptions): Promise DB column NULL). No + // fuzzy re-matching — conservative "aligned or nothing". + const fetchedAuthors = fetched.metadata?.authors + const authorsMatchFetched = + fetchedAuthors !== undefined && + finalAuthors.length === fetchedAuthors.length && + finalAuthors.every((a, i) => a === fetchedAuthors[i]) + const authorOrcids = authorsMatchFetched ? fetched.metadata?.authorOrcids : undefined + const metadata: IngestMetadata = { title: extracted.metadata.title ?? fetched.metadata?.title ?? classified.value, - authors: extracted.metadata.authors ?? fetched.metadata?.authors ?? [], + authors: finalAuthors, year: extracted.metadata.year ?? fetched.metadata?.year, venue: extracted.metadata.venue ?? fetched.metadata?.venue, abstract: extracted.metadata.abstract ?? fetched.metadata?.abstract, + authorOrcids, } const record: PaperRecord = { @@ -129,6 +151,7 @@ export async function ingest(raw: string, options: IngestOptions): Promise { } }) + describe('author_orcids [P2-04]', () => { + it('round-trips authorOrcids, including null entries for unknown authors', () => { + const db = openDb({ path: ':memory:' }) + try { + upsertPaper( + db, + makeRecord({ + slug: 'orcid-paper', + authors: ['Jane Doe', 'John Smith'], + authorOrcids: ['0000-0002-1825-0097', null], + }), + ) + + const row = getPaper(db, 'orcid-paper') + expect(row?.authors).toEqual(['Jane Doe', 'John Smith']) + expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + } finally { + db.close() + } + }) + + it('a paper upserted without authorOrcids reads back with authorOrcids undefined, authors intact', () => { + const db = openDb({ path: ':memory:' }) + try { + upsertPaper(db, makeRecord({ slug: 'no-orcids' })) + + const row = getPaper(db, 'no-orcids') + expect(row?.authorOrcids).toBeUndefined() + expect(row?.authors).toEqual(['Jane Doe', 'John Smith']) + } finally { + db.close() + } + }) + + it('an old row (author_orcids column NULL) reads back with authorOrcids undefined', () => { + const db = openDb({ path: ':memory:' }) + try { + // Simulate a pre-[P2-04] row inserted without touching the new column at all. + db.prepare( + `INSERT INTO papers (slug, title, authors, added_at) VALUES (?, ?, ?, ?)`, + ).run('legacy', 'Legacy Paper', JSON.stringify(['Old Author']), '2026-01-01T00:00:00.000Z') + + const row = getPaper(db, 'legacy') + expect(row?.authorOrcids).toBeUndefined() + expect(row?.authors).toEqual(['Old Author']) + } finally { + db.close() + } + }) + }) + it('listPapers orders most-recently-added first', () => { const db = openDb({ path: ':memory:' }) try { diff --git a/core/library/repo.ts b/core/library/repo.ts index dff5f83..99c2262 100644 --- a/core/library/repo.ts +++ b/core/library/repo.ts @@ -17,6 +17,12 @@ export interface PaperRecord { slug: string title: string authors: string[] + /** [P2-04] ORCIDs positionally aligned to `authors` — entry `i` is the bare + * ORCID id (e.g. `0000-0002-1825-0097`) for `authors[i]`, or `null` when + * that specific author's ORCID is unknown. `undefined` when no ORCID data + * is known for this paper at all (column is NULL — predates this feature + * or was never supplied), as distinct from an array of all-null entries. */ + authorOrcids?: (string | null)[] year?: number venue?: string doi?: string @@ -34,6 +40,7 @@ interface PaperRow { slug: string title: string authors: string | null + author_orcids: string | null year: number | null venue: string | null doi: string | null @@ -51,6 +58,9 @@ function toRecord(row: PaperRow): PaperRecord { slug: row.slug, title: row.title, authors: row.authors ? (JSON.parse(row.authors) as string[]) : [], + authorOrcids: row.author_orcids + ? (JSON.parse(row.author_orcids) as (string | null)[]) + : undefined, year: row.year ?? undefined, venue: row.venue ?? undefined, doi: row.doi ?? undefined, @@ -71,25 +81,27 @@ function toRecord(row: PaperRow): PaperRecord { */ export function upsertPaper(db: Database, paper: PaperRecord): void { db.prepare( - `INSERT INTO papers (slug, title, authors, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at) - VALUES (@slug, @title, @authors, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt) + `INSERT INTO papers (slug, title, authors, author_orcids, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at) + VALUES (@slug, @title, @authors, @authorOrcids, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt) ON CONFLICT(slug) DO UPDATE SET - title = excluded.title, - authors = excluded.authors, - year = excluded.year, - venue = excluded.venue, - doi = excluded.doi, - arxiv_id = excluded.arxiv_id, - abstract = excluded.abstract, - summary = excluded.summary, - md_path = excluded.md_path, - pdf_path = excluded.pdf_path, - sections = excluded.sections, - added_at = excluded.added_at`, + title = excluded.title, + authors = excluded.authors, + author_orcids = excluded.author_orcids, + year = excluded.year, + venue = excluded.venue, + doi = excluded.doi, + arxiv_id = excluded.arxiv_id, + abstract = excluded.abstract, + summary = excluded.summary, + md_path = excluded.md_path, + pdf_path = excluded.pdf_path, + sections = excluded.sections, + added_at = excluded.added_at`, ).run({ slug: paper.slug, title: paper.title, authors: JSON.stringify(paper.authors ?? []), + authorOrcids: paper.authorOrcids !== undefined ? JSON.stringify(paper.authorOrcids) : null, year: paper.year ?? null, venue: paper.venue ?? null, doi: paper.doi ?? null, diff --git a/core/store/db.test.ts b/core/store/db.test.ts index 0dd0c48..a197e68 100644 --- a/core/store/db.test.ts +++ b/core/store/db.test.ts @@ -60,7 +60,7 @@ describe('openDb', () => { const row = db2.prepare('SELECT * FROM papers WHERE slug = ?').get('p1') expect(row).toBeTruthy() const version = db2.pragma('user_version', { simple: true }) - expect(version).toBe(4) + expect(version).toBe(5) } finally { db2.close() } diff --git a/core/store/migrate.test.ts b/core/store/migrate.test.ts index bdf2d5c..1f9894c 100644 --- a/core/store/migrate.test.ts +++ b/core/store/migrate.test.ts @@ -37,7 +37,19 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(4) + expect(version).toBe(5) + }) + + it('adds the author_orcids column to papers [P2-04]', () => { + db = new Database(':memory:') + runMigrations(db) + + const columns = db + .prepare("PRAGMA table_info(papers)") + .all() + .map((row) => (row as { name: string }).name) + + expect(columns).toContain('author_orcids') }) it('creates the highlights table index', () => { @@ -64,7 +76,7 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(4) + expect(version).toBe(5) const row = db.prepare('SELECT * FROM papers WHERE slug = ?').get('a') expect(row).toBeTruthy() diff --git a/core/store/migrate.ts b/core/store/migrate.ts index ac13407..023a0fb 100644 --- a/core/store/migrate.ts +++ b/core/store/migrate.ts @@ -12,7 +12,7 @@ import type { Database } from 'better-sqlite3' -import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS } from './schema.js' +import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS } from './schema.js' export interface Migration { version: number @@ -25,6 +25,7 @@ export const MIGRATIONS: Migration[] = [ { version: 2, sql: 'ALTER TABLE papers ADD COLUMN summary TEXT;' }, { version: 3, sql: SCHEMA_V3_NOTES }, // [P2-01] notes table { version: 4, sql: SCHEMA_V4_HIGHLIGHTS }, // [P2-02] highlights table + { version: 5, sql: SCHEMA_V5_AUTHOR_ORCIDS }, // [P2-04] author_orcids column ] /** diff --git a/core/store/schema.ts b/core/store/schema.ts index a57dbed..246ce0a 100644 --- a/core/store/schema.ts +++ b/core/store/schema.ts @@ -96,3 +96,16 @@ CREATE TABLE IF NOT EXISTS highlights ( ); CREATE INDEX IF NOT EXISTS idx_highlights_paper ON highlights(paper_slug); ` + +// [P2-04] ORCID author badges. Authors stay a plain `authors TEXT` JSON +// array (see SCHEMA_V1) — this migration does NOT touch that column. ORCIDs +// live in a new, additive, nullable parallel column: a JSON array of +// `(string | null)` entries, positionally aligned to `authors` (index i of +// `author_orcids` is the ORCID for `authors[i]`, or null if that author's +// ORCID is unknown). Existing rows get NULL (no backfill) — NULL means "no +// ORCID data known for this paper," distinct from an array of all-null +// entries ("we checked, none of these authors have an ORCID on file"). +// Same purely-additive shape as the v2 `summary` column. +export const SCHEMA_V5_AUTHOR_ORCIDS = ` +ALTER TABLE papers ADD COLUMN author_orcids TEXT; +` diff --git a/src/app/DetailsPanel.test.tsx b/src/app/DetailsPanel.test.tsx index 47f664e..33eb6d2 100644 --- a/src/app/DetailsPanel.test.tsx +++ b/src/app/DetailsPanel.test.tsx @@ -1,14 +1,76 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest' -import { render, screen } from '@testing-library/react' -import { beforeEach, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { DetailsPanel } from './DetailsPanel' beforeEach(() => Object.defineProperty(window, 'vellum', { configurable: true, value: { getPaper: vi.fn().mockResolvedValue({ title: 'Paper', authors: ['Author'], year: 2026, summary: 'Short summary.', sections: [{ title: 'Introduction' }], addedAt: 't' }) } })) +afterEach(() => { + cleanup() +}) + it('shows stored summary and metadata', async () => { render() expect(await screen.findByText('Short summary.')).toBeInTheDocument() expect(screen.getByText('Author')).toBeInTheDocument() expect(screen.getByText('Introduction')).toBeInTheDocument() }) + +it('renders an ORCID badge for authors with an ORCID and plain text for those without', async () => { + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { + getPaper: vi.fn().mockResolvedValue({ + title: 'Paper', + authors: ['Jane Doe', 'John Smith'], + authorOrcids: ['0000-0002-1825-0097', null], + year: 2026, + addedAt: 't', + }), + }, + }) + render() + const link = await screen.findByRole('link', { name: 'ORCID profile for Jane Doe' }) + expect(link).toHaveAttribute('href', 'https://orcid.org/0000-0002-1825-0097') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + expect(screen.getByText('John Smith')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'ORCID profile for John Smith' })).not.toBeInTheDocument() +}) + +it('renders all authors as plain text when authorOrcids is absent (graceful absence)', async () => { + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { + getPaper: vi.fn().mockResolvedValue({ + title: 'Paper', + authors: ['Jane Doe', 'John Smith'], + year: 2026, + addedAt: 't', + }), + }, + }) + render() + await screen.findByText('Jane Doe') + expect(screen.getByText('John Smith')).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() +}) + +it('handles authorOrcids shorter than authors (defensive index access)', async () => { + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { + getPaper: vi.fn().mockResolvedValue({ + title: 'Paper', + authors: ['Jane Doe', 'John Smith'], + authorOrcids: ['0000-0002-1825-0097'], + year: 2026, + addedAt: 't', + }), + }, + }) + render() + expect(await screen.findByRole('link', { name: 'ORCID profile for Jane Doe' })).toBeInTheDocument() + expect(screen.getByText('John Smith')).toBeInTheDocument() +}) diff --git a/src/app/DetailsPanel.tsx b/src/app/DetailsPanel.tsx index 07806d5..4aed740 100644 --- a/src/app/DetailsPanel.tsx +++ b/src/app/DetailsPanel.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react' import styles from './DetailsPanel.module.css' +import { OrcidBadge } from './OrcidBadge' interface PaperDetails { title: string authors: string[] + authorOrcids?: (string | null)[] year?: number venue?: string doi?: string @@ -23,7 +25,20 @@ export function DetailsPanel({ slug }: { slug?: string }): JSX.Element { return
{paper.summary ? <>

Summary

{paper.summary}

: null}

{paper.title}

- {paper.authors.length ?

{paper.authors.join(', ')}

: null} + {paper.authors.length ? ( +

+ {paper.authors.map((name, index) => { + const orcid = paper.authorOrcids?.[index] + return ( + + {index > 0 ? : null} + {name} + {orcid ? : null} + + ) + })} +

+ ) : null} {[paper.year, paper.venue, paper.arxivId, paper.doi].filter(Boolean).map((item) =>

{item}

)} {paper.sections?.length ? <>

Sections

    {paper.sections.map((section, index) =>
  • {sectionTitle(section)}
  • )}
: null}
diff --git a/src/app/OrcidBadge.module.css b/src/app/OrcidBadge.module.css new file mode 100644 index 0000000..e4f8bf7 --- /dev/null +++ b/src/app/OrcidBadge.module.css @@ -0,0 +1,14 @@ +.badge { + display: inline-block; + margin-left: 0.25em; + font-size: 0.75em; + color: #a6ce39; + text-decoration: none; + border: 1px solid currentColor; + border-radius: 999px; + padding: 0 0.4em; +} + +.badge:hover { + text-decoration: underline; +} diff --git a/src/app/OrcidBadge.test.tsx b/src/app/OrcidBadge.test.tsx new file mode 100644 index 0000000..b4b4c59 --- /dev/null +++ b/src/app/OrcidBadge.test.tsx @@ -0,0 +1,13 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { expect, it } from 'vitest' +import { OrcidBadge } from './OrcidBadge' + +it('renders a link to the ORCID profile with accessible label and new-tab attrs', () => { + render() + const link = screen.getByRole('link', { name: 'ORCID profile for Jane Doe' }) + expect(link).toHaveAttribute('href', 'https://orcid.org/0000-0002-1825-0097') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') +}) diff --git a/src/app/OrcidBadge.tsx b/src/app/OrcidBadge.tsx new file mode 100644 index 0000000..064bcac --- /dev/null +++ b/src/app/OrcidBadge.tsx @@ -0,0 +1,15 @@ +import styles from './OrcidBadge.module.css' + +export function OrcidBadge({ name, orcid }: { name: string; orcid: string }): JSX.Element { + return ( + + ORCID + + ) +}