@cyanheads/pubmed-mcp-server 2.10.10 → 2.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +10 -3
  4. package/dist/mcp-server/resources/definitions/database-info.resource.js +6 -6
  5. package/dist/mcp-server/resources/definitions/database-info.resource.js.map +1 -1
  6. package/dist/mcp-server/tools/definitions/_schemas.d.ts +16 -0
  7. package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
  8. package/dist/mcp-server/tools/definitions/_schemas.js +20 -0
  9. package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +6 -0
  11. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/convert-ids.tool.js +28 -3
  13. package/dist/mcp-server/tools/definitions/convert-ids.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +27 -0
  15. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +166 -24
  17. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +1 -0
  19. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +10 -6
  21. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +4 -0
  23. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/find-related.tool.js +40 -4
  25. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -13
  28. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +42 -8
  31. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +4 -0
  33. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/search-articles.tool.js +37 -2
  35. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  36. package/dist/services/error-contracts.d.ts +17 -0
  37. package/dist/services/error-contracts.d.ts.map +1 -1
  38. package/dist/services/error-contracts.js +19 -0
  39. package/dist/services/error-contracts.js.map +1 -1
  40. package/dist/services/ncbi/formatting/citation-formatter.d.ts +4 -20
  41. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  42. package/dist/services/ncbi/formatting/citation-formatter.js +406 -31
  43. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  44. package/dist/services/ncbi/ncbi-service.d.ts +3 -2
  45. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  46. package/dist/services/ncbi/ncbi-service.js +22 -10
  47. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  48. package/dist/services/ncbi/parsing/article-parser.d.ts +45 -2
  49. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  50. package/dist/services/ncbi/parsing/article-parser.js +201 -2
  51. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  52. package/dist/services/ncbi/parsing/esummary-parser.d.ts +11 -1
  53. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  54. package/dist/services/ncbi/parsing/esummary-parser.js +48 -8
  55. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  56. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  57. package/dist/services/ncbi/parsing/pmc-article-parser.js +33 -4
  58. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  59. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +19 -0
  60. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  61. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +81 -2
  62. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  63. package/dist/services/ncbi/parsing/xml-helpers.d.ts +29 -3
  64. package/dist/services/ncbi/parsing/xml-helpers.d.ts.map +1 -1
  65. package/dist/services/ncbi/parsing/xml-helpers.js +46 -0
  66. package/dist/services/ncbi/parsing/xml-helpers.js.map +1 -1
  67. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  68. package/dist/services/ncbi/response-handler.js +96 -28
  69. package/dist/services/ncbi/response-handler.js.map +1 -1
  70. package/dist/services/ncbi/types.d.ts +172 -2
  71. package/dist/services/ncbi/types.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/server.json +3 -3
@@ -4,22 +4,90 @@
4
4
  * Pure TypeScript, zero dependencies, Workers-compatible.
5
5
  * @module src/services/ncbi/formatting/citation-formatter
6
6
  */
7
+ /**
8
+ * Whether a record cites a book rather than a journal article. Dispatch is on
9
+ * `recordType`, never on `publicationTypes`: a Bookshelf record's publication
10
+ * type is `Review` or `Study Guide`, so type strings cannot tell the two apart.
11
+ */
12
+ function isBookRecord(article) {
13
+ return article.recordType !== 'journal-article' && article.book !== undefined;
14
+ }
7
15
  // ---------------------------------------------------------------------------
8
16
  // Helpers
9
17
  // ---------------------------------------------------------------------------
10
18
  /**
11
19
  * Extract the publication year from a ParsedArticle.
12
- * Prefers `journalInfo.publicationDate.year`; falls back to the earliest
13
- * `articleDates` entry (typically the electronic pub date) before giving up.
14
- * Returns 'n.d.' (no date) when no year is available.
20
+ * Prefers `journalInfo.publicationDate.year`, then the containing book's own
21
+ * date, then the earliest `articleDates` entry (for a book, its contribution
22
+ * date) before giving up. Returns 'n.d.' (no date) when no year is available.
15
23
  */
16
24
  function getYear(article) {
17
25
  const journalYear = article.journalInfo?.publicationDate?.year;
18
26
  if (journalYear)
19
27
  return journalYear;
28
+ const bookYear = article.book?.pubDate ?? article.book?.beginningDate ?? article.book?.endingDate;
29
+ if (bookYear)
30
+ return bookYear;
20
31
  const articleYear = article.articleDates?.find((d) => d.year)?.year;
21
32
  return articleYear ?? 'n.d.';
22
33
  }
34
+ /**
35
+ * The date NLM prints for a book: a closed range for a book published over
36
+ * several years (`1993-2026`), otherwise the single publication year.
37
+ */
38
+ function bookDateSpan(book) {
39
+ if (book.beginningDate && book.endingDate && book.beginningDate !== book.endingDate) {
40
+ return `${book.beginningDate}-${book.endingDate}`;
41
+ }
42
+ return book.pubDate ?? book.beginningDate ?? book.endingDate;
43
+ }
44
+ /** `Place: Publisher`, or whichever half the record carries. */
45
+ function bookImprint(book) {
46
+ if (book.publisherLocation && book.publisher) {
47
+ return `${book.publisherLocation}: ${book.publisher}`;
48
+ }
49
+ return book.publisher ?? book.publisherLocation;
50
+ }
51
+ /** Where the book is readable — the NCBI Bookshelf permalink. */
52
+ function bookshelfUrl(book) {
53
+ return book.accession ? `https://www.ncbi.nlm.nih.gov/books/${book.accession}/` : undefined;
54
+ }
55
+ /**
56
+ * The DOI a citation may carry — the record's own, and on a whole book the
57
+ * book-level `Book/ELocationID` as a fallback. A chapter never inherits the
58
+ * containing book's DOI: that identifier resolves to the book, so citing a
59
+ * chapter with it points a reader at the wrong work. (#114)
60
+ */
61
+ function citableDoi(article) {
62
+ if (article.doi)
63
+ return article.doi;
64
+ return article.recordType === 'book' ? article.book?.doi : undefined;
65
+ }
66
+ /**
67
+ * The access URL for a book citation: the DOI when the record has one — APA and
68
+ * most style guides prefer it — and the Bookshelf permalink otherwise.
69
+ */
70
+ function bookAccessUrl(article) {
71
+ const doi = citableDoi(article);
72
+ return doi ? `https://doi.org/${doi}` : bookshelfUrl(article.book);
73
+ }
74
+ /** Book title with its medium marker, e.g. `GeneReviews® [Internet]`. */
75
+ function bookTitleWithMedium(book) {
76
+ if (!book.title)
77
+ return;
78
+ return book.medium ? `${book.title} [${book.medium}]` : book.title;
79
+ }
80
+ /** Strip a single trailing period so the caller can add its own. */
81
+ function stripTrailingPeriod(text) {
82
+ return text.replace(/\.\s*$/, '');
83
+ }
84
+ /**
85
+ * End a segment with exactly one period. An author list already closing on
86
+ * "et al." or on an initial keeps the period it has rather than gaining a second.
87
+ */
88
+ function terminate(text) {
89
+ return text.endsWith('.') ? text : `${text}.`;
90
+ }
23
91
  /**
24
92
  * Split a pages string like "45-67" into start and end components.
25
93
  * Handles en-dashes, em-dashes, and hyphens. Expands PubMed's truncated-end
@@ -38,6 +106,19 @@ function splitPages(pages) {
38
106
  return { start, end };
39
107
  return start ? { start } : {};
40
108
  }
109
+ /**
110
+ * The electronic article locator that stands in for a page range, or undefined.
111
+ *
112
+ * Returns a value only when the record carries no pagination. Publishers that
113
+ * report the article number as both `Pagination` and `ELocationID` (PLoS ONE,
114
+ * Scientific Reports) are already covered by the `pages` rendering, and printing
115
+ * both would duplicate the number in every style.
116
+ */
117
+ function articleLocator(journal) {
118
+ if (journal?.pages)
119
+ return;
120
+ return journal?.elocationId || undefined;
121
+ }
41
122
  /**
42
123
  * Collapse internal whitespace (including embedded newlines from structured
43
124
  * abstracts) to single spaces. Strict RIS parsers treat blank lines as record
@@ -46,16 +127,18 @@ function splitPages(pages) {
46
127
  function collapseWhitespace(text) {
47
128
  return text.replace(/\s+/g, ' ').trim();
48
129
  }
49
- /** PubMed `PublicationType` → BibTeX entry type. Defaults to `article`. */
130
+ /**
131
+ * PubMed `PublicationType` → BibTeX entry type. Defaults to `article`.
132
+ * Book records never reach this map — they dispatch on `recordType` instead,
133
+ * because PubMed labels a Bookshelf record `Review` or `Study Guide`.
134
+ */
50
135
  const BIBTEX_ENTRY_TYPE = {
51
136
  Book: 'book',
52
- 'Book Chapter': 'inbook',
53
137
  Preprint: 'misc',
54
138
  };
55
139
  /** PubMed `PublicationType` → RIS reference type. Defaults to `JOUR`. */
56
140
  const RIS_REFERENCE_TYPE = {
57
141
  Book: 'BOOK',
58
- 'Book Chapter': 'CHAP',
59
142
  Preprint: 'GEN',
60
143
  };
61
144
  function firstMappedType(types, map, fallback) {
@@ -195,6 +278,89 @@ function formatAuthorBibtex(author) {
195
278
  // ---------------------------------------------------------------------------
196
279
  // APA 7th Edition
197
280
  // ---------------------------------------------------------------------------
281
+ /**
282
+ * Format an editor in APA's "In E. E. Editor (Ed.)," position: initials first,
283
+ * surname last — the inverse of the author position.
284
+ */
285
+ function formatEditorApa(editor) {
286
+ if (editor.collectiveName)
287
+ return editor.collectiveName;
288
+ const initialsSource = editor.initials ??
289
+ editor.firstName
290
+ ?.split(/[\s-]+/)
291
+ .filter(Boolean)
292
+ .map((part) => part[0])
293
+ .join('');
294
+ const initials = initialsSource
295
+ ? Array.from(initialsSource.replace(/[^\p{L}]/gu, ''))
296
+ .map((c) => `${c}.`)
297
+ .join(' ')
298
+ : '';
299
+ return [initials, editor.lastName].filter(Boolean).join(' ');
300
+ }
301
+ /** APA editor list: comma-separated, `& ` before the last name. */
302
+ function formatEditorsApa(editors) {
303
+ const names = editors.map(formatEditorApa).filter(Boolean);
304
+ if (names.length === 0)
305
+ return '';
306
+ if (names.length === 1)
307
+ return names[0] ?? '';
308
+ return `${names.slice(0, -1).join(', ')}, & ${names.at(-1)}`;
309
+ }
310
+ /**
311
+ * Format a Bookshelf record as an APA 7th edition citation.
312
+ *
313
+ * Chapter in an edited book (APA 7 §10.3):
314
+ * ```
315
+ * Authors (Year). Chapter title. In E. Editor (Ed.), *Book title*. Publisher. URL
316
+ * ```
317
+ * A whole-book record drops the `In …` clause and italicizes its own title.
318
+ *
319
+ * A record crediting nobody at all — no authors, and for a whole book no
320
+ * editors either — moves its title into the author position instead (APA 7
321
+ * §9.12). Bookshelf makes that common: a whole-book record frequently credits
322
+ * neither. The title is not italicized there, and the year follows it. (#139)
323
+ */
324
+ function formatApaBook(article) {
325
+ const { book } = article;
326
+ const parts = [];
327
+ const authorStr = article.authors?.length ? formatAuthorsApa(article.authors) : '';
328
+ /** The record's own title, which stands in the author position when set. */
329
+ const leadTitle = article.recordType === 'book-chapter' ? article.title : book.title;
330
+ let titleLedTheReference = false;
331
+ if (authorStr) {
332
+ parts.push(terminate(authorStr));
333
+ }
334
+ else if (article.recordType === 'book' && book.editors?.length) {
335
+ // APA 7: an edited book with no authors of its own is cited from the editor
336
+ // position — `Last, F. M. (Ed.).` — rather than opening on the year.
337
+ parts.push(`${formatAuthorsApa(book.editors)} (${book.editors.length > 1 ? 'Eds.' : 'Ed.'}).`);
338
+ }
339
+ else if (leadTitle) {
340
+ parts.push(`${stripTrailingPeriod(leadTitle)}.`);
341
+ titleLedTheReference = true;
342
+ }
343
+ parts.push(`(${getYear(article)}).`);
344
+ if (article.recordType === 'book-chapter') {
345
+ if (article.title && !titleLedTheReference)
346
+ parts.push(`${stripTrailingPeriod(article.title)}.`);
347
+ const editorStr = book.editors?.length ? formatEditorsApa(book.editors) : '';
348
+ const editorLabel = (book.editors?.length ?? 0) > 1 ? 'Eds.' : 'Ed.';
349
+ const container = book.title ? `*${stripTrailingPeriod(book.title)}*` : '';
350
+ if (container) {
351
+ parts.push(editorStr ? `In ${editorStr} (${editorLabel}), ${container}.` : `In ${container}.`);
352
+ }
353
+ }
354
+ else if (book.title && !titleLedTheReference) {
355
+ parts.push(`*${stripTrailingPeriod(book.title)}*.`);
356
+ }
357
+ if (book.publisher)
358
+ parts.push(`${book.publisher}.`);
359
+ const url = bookAccessUrl(article);
360
+ if (url)
361
+ parts.push(url);
362
+ return parts.join(' ');
363
+ }
198
364
  /**
199
365
  * Format a PubMed article as an APA 7th edition citation.
200
366
  *
@@ -202,22 +368,36 @@ function formatAuthorBibtex(author) {
202
368
  * ```
203
369
  * Authors (Year). Title. *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
204
370
  * ```
371
+ * With no author the title takes the author position (APA 7 §9.12):
372
+ * ```
373
+ * Title. (Year). *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
374
+ * ```
375
+ * A Bookshelf record routes to {@link formatApaBook}.
205
376
  */
206
377
  export function formatApa(article) {
378
+ if (isBookRecord(article))
379
+ return formatApaBook(article);
207
380
  const parts = [];
208
381
  // Authors — ensure trailing period (individual author initials end with '.',
209
382
  // but collective names do not, which would otherwise produce "Name (Year).")
210
383
  const authorStr = article.authors?.length ? formatAuthorsApa(article.authors) : '';
384
+ // APA 7 §9.12: with no author the title takes the author position — the
385
+ // reference reads `Title. (Year). *Journal*, …` rather than opening on the
386
+ // date. It is not italicized there. (#139)
387
+ const titleLedTheReference = !authorStr && Boolean(article.title);
211
388
  if (authorStr) {
212
- parts.push(authorStr.endsWith('.') ? authorStr : `${authorStr}.`);
389
+ parts.push(terminate(authorStr));
390
+ }
391
+ else if (article.title) {
392
+ parts.push(`${stripTrailingPeriod(article.title)}.`);
213
393
  }
214
394
  // Year
215
395
  const year = getYear(article);
216
396
  parts.push(`(${year}).`);
217
397
  // Title — use as-is from PubMed (sentence case already assumed)
218
- if (article.title) {
398
+ if (article.title && !titleLedTheReference) {
219
399
  // Strip trailing period from title if present; we add our own
220
- const title = article.title.replace(/\.\s*$/, '');
400
+ const title = stripTrailingPeriod(article.title);
221
401
  parts.push(`${title}.`);
222
402
  }
223
403
  // Journal, volume, issue, pages
@@ -233,6 +413,12 @@ export function formatApa(article) {
233
413
  if (journal.pages) {
234
414
  journalPart += `, ${journal.pages}`;
235
415
  }
416
+ else {
417
+ // APA 7 p. 294-295: an article number takes the page range's place
418
+ const locator = articleLocator(journal);
419
+ if (locator)
420
+ journalPart += `, Article ${locator}`;
421
+ }
236
422
  journalPart += '.';
237
423
  parts.push(journalPart);
238
424
  }
@@ -253,17 +439,68 @@ export function formatApa(article) {
253
439
  * Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
254
440
  * ```
255
441
  */
442
+ /** MLA renders editors first-name-first after `edited by`. */
443
+ function formatEditorsMla(editors) {
444
+ const names = editors
445
+ .map((editor) => editor.collectiveName
446
+ ? editor.collectiveName
447
+ : [editor.firstName, editor.lastName].filter(Boolean).join(' '))
448
+ .filter(Boolean);
449
+ if (names.length === 0)
450
+ return '';
451
+ if (names.length === 1)
452
+ return names[0] ?? '';
453
+ if (names.length === 2)
454
+ return `${names[0]} and ${names[1]}`;
455
+ return `${names[0]}, et al.`;
456
+ }
457
+ /**
458
+ * Format a Bookshelf record as an MLA 9th edition citation.
459
+ *
460
+ * ```
461
+ * Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher, Year.
462
+ * ```
463
+ * A whole-book record italicizes its own title in place of the quoted chapter.
464
+ */
465
+ function formatMlaBook(article) {
466
+ const { book } = article;
467
+ const parts = [];
468
+ const authorStr = article.authors?.length ? formatAuthorsMla(article.authors) : '';
469
+ if (authorStr)
470
+ parts.push(terminate(authorStr));
471
+ if (article.recordType === 'book-chapter' && article.title) {
472
+ parts.push(`"${stripTrailingPeriod(article.title)}."`);
473
+ }
474
+ const detailParts = [];
475
+ if (book.title)
476
+ detailParts.push(`*${stripTrailingPeriod(book.title)}*`);
477
+ const editorStr = book.editors?.length ? formatEditorsMla(book.editors) : '';
478
+ if (editorStr)
479
+ detailParts.push(`edited by ${editorStr}`);
480
+ if (book.edition)
481
+ detailParts.push(book.edition);
482
+ if (book.publisher)
483
+ detailParts.push(book.publisher);
484
+ const year = getYear(article);
485
+ if (year !== 'n.d.')
486
+ detailParts.push(year);
487
+ if (detailParts.length)
488
+ parts.push(`${detailParts.join(', ')}.`);
489
+ return parts.join(' ');
490
+ }
256
491
  export function formatMla(article) {
492
+ if (isBookRecord(article))
493
+ return formatMlaBook(article);
257
494
  const parts = [];
258
495
  // Authors
259
496
  const authorStr = article.authors?.length ? formatAuthorsMla(article.authors) : '';
260
497
  if (authorStr) {
261
498
  // Ensure author string ends with period
262
- parts.push(authorStr.endsWith('.') ? authorStr : `${authorStr}.`);
499
+ parts.push(terminate(authorStr));
263
500
  }
264
501
  // Title in quotes
265
502
  if (article.title) {
266
- const title = article.title.replace(/\.\s*$/, '');
503
+ const title = stripTrailingPeriod(article.title);
267
504
  parts.push(`"${title}."`);
268
505
  }
269
506
  // Journal and publication details
@@ -286,6 +523,13 @@ export function formatMla(article) {
286
523
  const isRange = /[-\u2013\u2014]/.test(journal.pages);
287
524
  detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
288
525
  }
526
+ else {
527
+ // MLA 9 codifies no article-number form; citation guides converge on
528
+ // "art. <value>" in the page position.
529
+ const locator = articleLocator(journal);
530
+ if (locator)
531
+ detailParts.push(`art. ${locator}`);
532
+ }
289
533
  parts.push(`${detailParts.join(', ')}.`);
290
534
  }
291
535
  // DOI
@@ -312,7 +556,15 @@ export function formatMla(article) {
312
556
  */
313
557
  export function formatBibtex(article) {
314
558
  const key = `pmid${article.pmid}`;
315
- const entryType = firstMappedType(article.publicationTypes, BIBTEX_ENTRY_TYPE, 'article');
559
+ const book = isBookRecord(article) ? article.book : undefined;
560
+ // `@incollection` is a chapter in a book gathered from several contributors;
561
+ // `@inbook` is a part attributed to the book's own author, which a Bookshelf
562
+ // chapter is not. A whole-book record is plainly `@book`.
563
+ const entryType = book
564
+ ? article.recordType === 'book'
565
+ ? 'book'
566
+ : 'incollection'
567
+ : firstMappedType(article.publicationTypes, BIBTEX_ENTRY_TYPE, 'article');
316
568
  const fields = [];
317
569
  // Authors
318
570
  if (article.authors?.length) {
@@ -320,16 +572,34 @@ export function formatBibtex(article) {
320
572
  if (authorStr)
321
573
  fields.push(['author', authorStr]);
322
574
  }
323
- // Title — strip trailing period; biblatex styles append their own
324
- if (article.title) {
325
- const title = article.title.replace(/\.\s*$/, '');
326
- fields.push(['title', `{${escapeBibtex(title)}}`]);
575
+ // Title — strip trailing period; biblatex styles append their own.
576
+ // A whole-book record's own title is the book title, so it is not repeated
577
+ // as a `booktitle` below.
578
+ const title = book && article.recordType === 'book' ? book.title : article.title;
579
+ if (title) {
580
+ fields.push(['title', `{${escapeBibtex(stripTrailingPeriod(title))}}`]);
327
581
  }
328
- // Journal
582
+ // Container — the journal, or the book a chapter sits in
329
583
  const journal = article.journalInfo;
330
584
  if (journal?.title) {
331
585
  fields.push(['journal', escapeBibtex(journal.title)]);
332
586
  }
587
+ if (book && article.recordType === 'book-chapter' && book.title) {
588
+ fields.push(['booktitle', escapeBibtex(book.title)]);
589
+ }
590
+ if (book?.editors?.length) {
591
+ const editorStr = book.editors.map(formatAuthorBibtex).filter(Boolean).join(' and ');
592
+ if (editorStr)
593
+ fields.push(['editor', editorStr]);
594
+ }
595
+ if (book?.publisher)
596
+ fields.push(['publisher', escapeBibtex(book.publisher)]);
597
+ if (book?.publisherLocation)
598
+ fields.push(['address', escapeBibtex(book.publisherLocation)]);
599
+ if (book?.edition)
600
+ fields.push(['edition', escapeBibtex(book.edition)]);
601
+ if (book?.collectionTitle)
602
+ fields.push(['series', escapeBibtex(book.collectionTitle)]);
333
603
  // Year
334
604
  const year = getYear(article);
335
605
  if (year !== 'n.d.') {
@@ -343,18 +613,30 @@ export function formatBibtex(article) {
343
613
  if (journal?.issue) {
344
614
  fields.push(['number', escapeBibtex(journal.issue)]);
345
615
  }
346
- // Pages
616
+ // Pages — or, with no pagination, biblatex's `eid` for the article number.
617
+ // Classic BibTeX has no article-number field and overloading `pages` is
618
+ // imprecise; `eid` is broadly supported (acmart included).
347
619
  if (journal?.pages) {
348
620
  fields.push(['pages', escapeBibtex(journal.pages)]);
349
621
  }
350
- // ISSN
622
+ else if (!book) {
623
+ const locator = articleLocator(journal);
624
+ if (locator)
625
+ fields.push(['eid', escapeBibtex(locator)]);
626
+ }
627
+ // ISSN, or a book's ISBNs — a book commonly carries a print and an
628
+ // electronic one, and dropping either loses a real identifier
351
629
  const issn = journal?.issn ?? journal?.eIssn;
352
630
  if (issn) {
353
631
  fields.push(['issn', escapeBibtex(issn)]);
354
632
  }
633
+ if (book?.isbns?.length) {
634
+ fields.push(['isbn', book.isbns.map(escapeBibtex).join(', ')]);
635
+ }
355
636
  // DOI
356
- if (article.doi) {
357
- fields.push(['doi', article.doi]);
637
+ const doi = citableDoi(article);
638
+ if (doi) {
639
+ fields.push(['doi', doi]);
358
640
  }
359
641
  // PMID
360
642
  fields.push(['pmid', article.pmid]);
@@ -362,6 +644,12 @@ export function formatBibtex(article) {
362
644
  if (article.pmcId) {
363
645
  fields.push(['pmcid', article.pmcId]);
364
646
  }
647
+ // Bookshelf permalink — where the book is actually readable
648
+ if (book) {
649
+ const url = bookshelfUrl(book);
650
+ if (url)
651
+ fields.push(['url', url]);
652
+ }
365
653
  // Keywords — merge article keywords with MeSH descriptor names
366
654
  const keywordSet = new Set();
367
655
  for (const k of article.keywords ?? [])
@@ -397,8 +685,13 @@ export function formatRis(article) {
397
685
  if (value)
398
686
  lines.push(`${code} - ${value}`);
399
687
  };
400
- // Type of reference — map from PubMed publication types
401
- const refType = firstMappedType(article.publicationTypes, RIS_REFERENCE_TYPE, 'JOUR');
688
+ const book = isBookRecord(article) ? article.book : undefined;
689
+ // Type of reference — the record type for a book, else the publication types
690
+ const refType = book
691
+ ? article.recordType === 'book'
692
+ ? 'BOOK'
693
+ : 'CHAP'
694
+ : firstMappedType(article.publicationTypes, RIS_REFERENCE_TYPE, 'JOUR');
402
695
  lines.push(`TY - ${refType}`);
403
696
  // Authors — one AU tag per author
404
697
  if (article.authors?.length) {
@@ -417,7 +710,8 @@ export function formatRis(article) {
417
710
  }
418
711
  // Title
419
712
  tag('TI', article.title);
420
- // Journal
713
+ // Container — the journal, or the book a chapter sits in. `BT` is the book
714
+ // title; on a whole-book record it would only repeat `TI`, so it is omitted.
421
715
  const journal = article.journalInfo;
422
716
  if (journal?.title) {
423
717
  tag('JF', journal.title);
@@ -425,6 +719,22 @@ export function formatRis(article) {
425
719
  if (journal?.isoAbbreviation) {
426
720
  tag('JO', journal.isoAbbreviation);
427
721
  }
722
+ if (book) {
723
+ if (article.recordType === 'book-chapter')
724
+ tag('BT', book.title);
725
+ for (const editor of book.editors ?? []) {
726
+ const last = editor.lastName ?? '';
727
+ const first = editor.firstName ?? '';
728
+ if (editor.collectiveName)
729
+ tag('A2', editor.collectiveName);
730
+ else if (last || first)
731
+ tag('A2', first ? `${last}, ${first}` : last);
732
+ }
733
+ tag('PB', book.publisher);
734
+ tag('CY', book.publisherLocation);
735
+ tag('ET', book.edition);
736
+ tag('T3', book.collectionTitle);
737
+ }
428
738
  // Year
429
739
  const year = getYear(article);
430
740
  if (year !== 'n.d.') {
@@ -433,16 +743,24 @@ export function formatRis(article) {
433
743
  // Volume & Issue
434
744
  tag('VL', journal?.volume);
435
745
  tag('IS', journal?.issue);
436
- // Pages — split into start/end, expanding PubMed's truncated-end convention
746
+ // Pages — split into start/end, expanding PubMed's truncated-end convention.
747
+ // With no pagination, the article number goes on `C7` (the attested RIS
748
+ // convention for Article Number), never on SP/EP, which hold absolute pages.
437
749
  if (journal?.pages) {
438
750
  const { start, end } = splitPages(journal.pages);
439
751
  tag('SP', start);
440
752
  tag('EP', end);
441
753
  }
442
- // ISSN — prefer print ISSN, fall back to electronic
754
+ else if (!book) {
755
+ tag('C7', articleLocator(journal));
756
+ }
757
+ // SN carries the ISSN for a serial and the ISBN for a book; a book with both
758
+ // a print and an electronic ISBN gets one line each.
443
759
  tag('SN', journal?.issn ?? journal?.eIssn);
760
+ for (const isbn of book?.isbns ?? [])
761
+ tag('SN', isbn);
444
762
  // DOI (without URL prefix — RIS DO tag holds the bare DOI)
445
- tag('DO', article.doi);
763
+ tag('DO', citableDoi(article));
446
764
  // Accession number (PMID)
447
765
  tag('AN', article.pmid);
448
766
  // PubMed URL
@@ -451,6 +769,12 @@ export function formatRis(article) {
451
769
  if (article.pmcId) {
452
770
  lines.push(`UR - https://pmc.ncbi.nlm.nih.gov/articles/${article.pmcId}/`);
453
771
  }
772
+ // Bookshelf URL — where a book record is actually readable
773
+ if (book) {
774
+ const url = bookshelfUrl(book);
775
+ if (url)
776
+ lines.push(`UR - ${url}`);
777
+ }
454
778
  // Keywords — merge article keywords with MeSH descriptor names
455
779
  const keywordSet = new Set();
456
780
  for (const k of article.keywords ?? [])
@@ -525,24 +849,67 @@ function formatAuthorsVancouver(authors) {
525
849
  * ```
526
850
  * Journal name uses the NLM/ISO abbreviation when available; pages are used as
527
851
  * PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
528
- * period so it stays copy-pasteable.
852
+ * period so it stays copy-pasteable. An article number replaces nothing — with
853
+ * no pagination it trails the source as an NLM note (`. pii: 2400512.`).
529
854
  */
855
+ /**
856
+ * Format a Bookshelf record as a Vancouver (NLM) reference, following *Citing
857
+ * Medicine* 2e Ch. 22 §C, Contributions to Books on the Internet:
858
+ * ```
859
+ * Authors. Chapter title. In: Editors, editors. Book title [Internet].
860
+ * Place: Publisher; date. Available from: URL
861
+ * ```
862
+ * A whole-book record drops the contribution and the `In:`, taking the book
863
+ * title as its own. `[cited …]` and the extent (`[about 41 p.]`) are part of the
864
+ * NLM pattern but neither is derivable from an EFetch record, so both are
865
+ * omitted rather than invented. Editors stand in for absent authors on a whole
866
+ * book, which is the NLM form for an edited work.
867
+ */
868
+ function formatVancouverBook(article) {
869
+ const { book } = article;
870
+ const segments = [];
871
+ const authorStr = article.authors?.length ? formatAuthorsVancouver(article.authors) : '';
872
+ const editorStr = book.editors?.length ? formatAuthorsVancouver(book.editors) : '';
873
+ if (authorStr)
874
+ segments.push(terminate(authorStr));
875
+ if (article.recordType === 'book-chapter') {
876
+ if (article.title)
877
+ segments.push(`${stripTrailingPeriod(article.title)}.`);
878
+ segments.push(editorStr ? `In: ${editorStr}, editors.` : 'In:');
879
+ }
880
+ else if (!authorStr && editorStr) {
881
+ segments.push(`${editorStr}, editors.`);
882
+ }
883
+ const container = bookTitleWithMedium(book);
884
+ if (container)
885
+ segments.push(`${stripTrailingPeriod(container)}.`);
886
+ const source = [bookImprint(book), bookDateSpan(book)].filter(Boolean).join('; ');
887
+ if (source)
888
+ segments.push(`${source}.`);
889
+ // No trailing period — it would be read as part of the URL
890
+ const url = bookshelfUrl(book);
891
+ if (url)
892
+ segments.push(`Available from: ${url}`);
893
+ return segments.join(' ');
894
+ }
530
895
  export function formatVancouver(article) {
896
+ if (isBookRecord(article))
897
+ return formatVancouverBook(article);
531
898
  const segments = [];
532
899
  // Authors — terminate with a period unless the list already ends in "et al."
533
900
  const authorStr = article.authors?.length ? formatAuthorsVancouver(article.authors) : '';
534
901
  if (authorStr) {
535
- segments.push(authorStr.endsWith('.') ? authorStr : `${authorStr}.`);
902
+ segments.push(terminate(authorStr));
536
903
  }
537
904
  // Title — sentence case as supplied, single terminating period
538
905
  if (article.title) {
539
- segments.push(`${article.title.replace(/\.\s*$/, '')}.`);
906
+ segments.push(`${stripTrailingPeriod(article.title)}.`);
540
907
  }
541
908
  // Journal — NLM/ISO abbreviation preferred, full title as fallback
542
909
  const journal = article.journalInfo;
543
910
  const journalName = journal?.isoAbbreviation ?? journal?.title;
544
911
  if (journalName) {
545
- segments.push(`${journalName.replace(/\.\s*$/, '')}.`);
912
+ segments.push(`${stripTrailingPeriod(journalName)}.`);
546
913
  }
547
914
  // Source — "Year;Volume(Issue):Pages."
548
915
  const year = getYear(article);
@@ -559,6 +926,14 @@ export function formatVancouver(article) {
559
926
  }
560
927
  if (source)
561
928
  segments.push(`${source}.`);
929
+ // Article number — NLM's note form for a publisher locator that is not
930
+ // pagination ("Euro Surveill. 2008 May 8;13(19). pii: 18863."): a trailing
931
+ // note after Year;Volume(Issue), never inside the colon slot.
932
+ const locator = articleLocator(journal);
933
+ if (locator) {
934
+ const label = journal?.elocationIdType;
935
+ segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
936
+ }
562
937
  // DOI — NLM "doi: <doi>" form; no trailing period (would corrupt the DOI)
563
938
  if (article.doi) {
564
939
  segments.push(`doi: ${article.doi}`);