@cyanheads/pubmed-mcp-server 2.10.19 → 2.10.21

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 (110) hide show
  1. package/AGENTS.md +8 -5
  2. package/CLAUDE.md +8 -5
  3. package/Dockerfile +12 -3
  4. package/README.md +12 -11
  5. package/changelog/2.10.x/2.10.20.md +63 -0
  6. package/changelog/2.10.x/2.10.21.md +42 -0
  7. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
  8. package/dist/mcp-server/tools/definitions/_text.js +10 -5
  9. package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +8 -0
  11. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +64 -14
  13. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +44 -9
  15. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +815 -563
  17. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +5 -5
  19. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/find-related.tool.js +12 -8
  21. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
  23. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/format-citations.tool.js +53 -7
  25. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +11 -7
  27. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +72 -34
  29. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +2 -1
  31. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +11 -3
  33. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +18 -0
  35. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +96 -13
  37. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -3
  39. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +7 -5
  41. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +6 -4
  43. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/search-articles.tool.js +33 -11
  45. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +2 -1
  47. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/definitions/spell-check.tool.js +5 -1
  49. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  50. package/dist/services/error-contracts.d.ts +1 -1
  51. package/dist/services/error-contracts.d.ts.map +1 -1
  52. package/dist/services/error-contracts.js +1 -1
  53. package/dist/services/error-contracts.js.map +1 -1
  54. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  55. package/dist/services/europe-pmc/api-client.js +4 -3
  56. package/dist/services/europe-pmc/api-client.js.map +1 -1
  57. package/dist/services/fetch-redaction.d.ts +23 -0
  58. package/dist/services/fetch-redaction.d.ts.map +1 -0
  59. package/dist/services/fetch-redaction.js +62 -0
  60. package/dist/services/fetch-redaction.js.map +1 -0
  61. package/dist/services/ncbi/api-client.d.ts +3 -0
  62. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  63. package/dist/services/ncbi/api-client.js +21 -8
  64. package/dist/services/ncbi/api-client.js.map +1 -1
  65. package/dist/services/ncbi/formatting/citation-formatter.d.ts +42 -2
  66. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  67. package/dist/services/ncbi/formatting/citation-formatter.js +370 -91
  68. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  69. package/dist/services/ncbi/ncbi-service.d.ts +16 -1
  70. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  71. package/dist/services/ncbi/ncbi-service.js +117 -71
  72. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  73. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  74. package/dist/services/ncbi/parsing/article-parser.js +116 -61
  75. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  76. package/dist/services/ncbi/parsing/esummary-parser.d.ts +9 -0
  77. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  78. package/dist/services/ncbi/parsing/esummary-parser.js +26 -2
  79. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  80. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +8 -7
  81. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  82. package/dist/services/ncbi/parsing/pmc-article-parser.js +633 -185
  83. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  84. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +102 -5
  85. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  86. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +389 -28
  87. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  88. package/dist/services/ncbi/parsing/text-helpers.d.ts +8 -6
  89. package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
  90. package/dist/services/ncbi/parsing/text-helpers.js +95 -19
  91. package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
  92. package/dist/services/ncbi/response-handler.d.ts +20 -12
  93. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  94. package/dist/services/ncbi/response-handler.js +115 -19
  95. package/dist/services/ncbi/response-handler.js.map +1 -1
  96. package/dist/services/ncbi/types.d.ts +44 -4
  97. package/dist/services/ncbi/types.d.ts.map +1 -1
  98. package/dist/services/openalex/api-client.d.ts.map +1 -1
  99. package/dist/services/openalex/api-client.js +4 -3
  100. package/dist/services/openalex/api-client.js.map +1 -1
  101. package/dist/services/openalex/openalex-service.d.ts +10 -6
  102. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  103. package/dist/services/openalex/openalex-service.js +13 -9
  104. package/dist/services/openalex/openalex-service.js.map +1 -1
  105. package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
  106. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  107. package/dist/services/unpaywall/unpaywall-service.js +18 -8
  108. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  109. package/package.json +14 -12
  110. package/server.json +10 -24
@@ -4,6 +4,27 @@
4
4
  * Pure TypeScript, zero dependencies, Workers-compatible.
5
5
  * @module src/services/ncbi/formatting/citation-formatter
6
6
  */
7
+ /**
8
+ * NCBI `RefType`s that qualify the cited article itself — it was retracted,
9
+ * corrected, or questioned. A link from a record that is itself a notice
10
+ * (`RetractionOf`, `ErratumFor`) and comment or update links are not among them:
11
+ * citing a retraction notice is not citing a retracted work. (#215)
12
+ */
13
+ export const CITATION_NOTICE_TYPES = [
14
+ 'RetractionIn',
15
+ 'RetractedandRepublishedIn',
16
+ 'ExpressionOfConcernIn',
17
+ 'ErratumIn',
18
+ 'CorrectedandRepublishedIn',
19
+ ];
20
+ const NOTICE_TYPES = new Set(CITATION_NOTICE_TYPES);
21
+ function isCitationNotice(link) {
22
+ return NOTICE_TYPES.has(link.refType);
23
+ }
24
+ /** The record's linked notices that qualify the cited article, in NCBI's order. */
25
+ export function citationNotices(article) {
26
+ return (article.commentsCorrections ?? []).filter(isCitationNotice);
27
+ }
7
28
  /**
8
29
  * Whether a record cites a book rather than a journal article. Dispatch is on
9
30
  * `recordType`, never on `publicationTypes`: a Bookshelf record's publication
@@ -12,19 +33,100 @@
12
33
  function isBookRecord(article) {
13
34
  return article.recordType !== 'journal-article' && article.book !== undefined;
14
35
  }
15
- // ---------------------------------------------------------------------------
16
- // Helpers
17
- // ---------------------------------------------------------------------------
36
+ /** Vancouver (NLM) month abbreviations: three letters, no period. */
37
+ const NLM_MONTHS = [
38
+ 'Jan',
39
+ 'Feb',
40
+ 'Mar',
41
+ 'Apr',
42
+ 'May',
43
+ 'Jun',
44
+ 'Jul',
45
+ 'Aug',
46
+ 'Sep',
47
+ 'Oct',
48
+ 'Nov',
49
+ 'Dec',
50
+ ];
51
+ /** MLA 9 month forms: names longer than four letters are abbreviated. */
52
+ const MLA_MONTHS = [
53
+ 'Jan.',
54
+ 'Feb.',
55
+ 'Mar.',
56
+ 'Apr.',
57
+ 'May',
58
+ 'June',
59
+ 'July',
60
+ 'Aug.',
61
+ 'Sept.',
62
+ 'Oct.',
63
+ 'Nov.',
64
+ 'Dec.',
65
+ ];
66
+ /**
67
+ * Read one of a Bookshelf chapter's own dates. Bookshelf writes months and days
68
+ * as numbers; a month that is not one is dropped together with its day rather
69
+ * than guessed, so the date keeps only the parts that can be stated.
70
+ */
71
+ function chapterDateOfType(article, dateType) {
72
+ const date = article.articleDates?.find((d) => d.dateType === dateType);
73
+ if (!date?.year)
74
+ return;
75
+ const month = Number(date.month);
76
+ if (!Number.isInteger(month) || month < 1 || month > 12)
77
+ return { year: date.year };
78
+ const day = Number(date.day);
79
+ return Number.isInteger(day) && day >= 1 && day <= 31
80
+ ? { year: date.year, month, day }
81
+ : { year: date.year, month };
82
+ }
83
+ /** A chapter's `ContributionDate` and `DateRevised`; both absent on any other record type. */
84
+ function chapterDates(article) {
85
+ if (article.recordType !== 'book-chapter')
86
+ return {};
87
+ return {
88
+ contributed: chapterDateOfType(article, 'ContributionDate'),
89
+ revised: chapterDateOfType(article, 'DateRevised'),
90
+ };
91
+ }
92
+ /**
93
+ * The date a Bookshelf chapter is cited by: its last revision when it has one
94
+ * (APA and MLA both cite a last-updated date), otherwise its contribution date.
95
+ * Never the containing book's date, which records when the book began. (#189)
96
+ */
97
+ function chapterDate(article) {
98
+ const { contributed, revised } = chapterDates(article);
99
+ return revised ?? contributed;
100
+ }
101
+ /** `20 Dec. 2018`, `July 2025`, or `2025` — MLA 9's day-month-year order. */
102
+ function formatDateMla(date) {
103
+ const month = date.month ? MLA_MONTHS[date.month - 1] : undefined;
104
+ return [date.day, month, date.year].filter(Boolean).join(' ');
105
+ }
106
+ /** `2018 Dec 20`, `2025 Jul`, or `2025` — NLM's year-month-day order. */
107
+ function formatDateNlm(date) {
108
+ const month = date.month ? NLM_MONTHS[date.month - 1] : undefined;
109
+ return [date.year, month, date.day].filter(Boolean).join(' ');
110
+ }
111
+ /** RIS `DA` form, zero-padded: `2018/12/20`, `2025/07`, or `2025`. */
112
+ function formatDateRis(date) {
113
+ const pad = (n) => (n ? String(n).padStart(2, '0') : undefined);
114
+ return [date.year, pad(date.month), pad(date.day)].filter(Boolean).join('/');
115
+ }
18
116
  /**
19
117
  * Extract the publication year from a ParsedArticle.
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.
118
+ * Prefers `journalInfo.publicationDate.year`, then a Bookshelf chapter's own
119
+ * date ({@link chapterDate}), then the containing book's date, then the first
120
+ * dated `articleDates` entry before giving up. Returns 'n.d.' (no date) when no
121
+ * year is available.
23
122
  */
24
123
  function getYear(article) {
25
124
  const journalYear = article.journalInfo?.publicationDate?.year;
26
125
  if (journalYear)
27
126
  return journalYear;
127
+ const chapterYear = chapterDate(article)?.year;
128
+ if (chapterYear)
129
+ return chapterYear;
28
130
  const bookYear = article.book?.pubDate ?? article.book?.beginningDate ?? article.book?.endingDate;
29
131
  if (bookYear)
30
132
  return bookYear;
@@ -33,11 +135,12 @@ function getYear(article) {
33
135
  }
34
136
  /**
35
137
  * 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.
138
+ * several years (`1993-2026`), an open one for a book still being updated
139
+ * (`2000-`), otherwise the single publication year.
37
140
  */
38
141
  function bookDateSpan(book) {
39
- if (book.beginningDate && book.endingDate && book.beginningDate !== book.endingDate) {
40
- return `${book.beginningDate}-${book.endingDate}`;
142
+ if (book.beginningDate && book.beginningDate !== book.endingDate) {
143
+ return `${book.beginningDate}-${book.endingDate ?? ''}`;
41
144
  }
42
145
  return book.pubDate ?? book.beginningDate ?? book.endingDate;
43
146
  }
@@ -107,18 +210,163 @@ function splitPages(pages) {
107
210
  return start ? { start } : {};
108
211
  }
109
212
  /**
110
- * The electronic article locator that stands in for a page range, or undefined.
213
+ * The electronic article locator that Vancouver, BibTeX, and RIS print in place
214
+ * of a page range, or undefined.
111
215
  *
112
216
  * Returns a value only when the record carries no pagination. Publishers that
113
217
  * report the article number as both `Pagination` and `ELocationID` (PLoS ONE,
114
218
  * Scientific Reports) are already covered by the `pages` rendering, and printing
115
- * both would duplicate the number in every style.
219
+ * both would duplicate the number. APA and MLA use {@link articleNumber}.
116
220
  */
117
221
  function articleLocator(journal) {
118
222
  if (journal?.pages)
119
223
  return;
120
224
  return journal?.elocationId || undefined;
121
225
  }
226
+ /**
227
+ * The article number APA and MLA cite in place of a page range: the record's
228
+ * non-DOI locator when it has no pagination, or when its pagination is that
229
+ * same value — NCBI copies an article number into `MedlinePgn` (Frontiers
230
+ * `662953`, Springer `42`). A page that matches no locator stays a page, though
231
+ * some are article numbers the record cannot identify (an Elsevier `105471`
232
+ * beside an `S…` pii looks exactly like a Lancet page `2413`). (#217)
233
+ */
234
+ function articleNumber(journal) {
235
+ if (!journal?.elocationId)
236
+ return;
237
+ return !journal.pages || journal.pages === journal.elocationId ? journal.elocationId : undefined;
238
+ }
239
+ /** One range inside NLM's pagination: a start, a dash, an end. */
240
+ const PAGE_RANGE = /([^\s,;\u{2013}\u{2014}-]+)\s*[-\u{2013}\u{2014}]\s*([^\s,;\u{2013}\u{2014}-]+)/gu;
241
+ /**
242
+ * The end of a range as NLM's elision means it. An end made of digits is
243
+ * written against the start's digits and takes the start's prefix (`637-41` →
244
+ * `641`, `S5-15` → `S15`, `1255.e1-5` → `1255.e5`); any other end is already
245
+ * whole (`878.e8`, `e65`, `xv`).
246
+ */
247
+ function fullPageEnd(start, end) {
248
+ const startParts = /^(.*?)(\d+)$/.exec(start);
249
+ if (!startParts || !/^\d+$/.test(end))
250
+ return end;
251
+ const [, prefix = '', digits = ''] = startParts;
252
+ const elided = end.length < digits.length;
253
+ return `${prefix}${elided ? digits.slice(0, digits.length - end.length) : ''}${end}`;
254
+ }
255
+ /**
256
+ * APA 7 pagination: each range written in full with an en dash (`637-41` →
257
+ * `637–641`, `S5-15` → `S5–S15`), the rest of NLM's text as given — a list's
258
+ * separators and labels too (`1407-8; author reply 1409-10` → `1407–1408;
259
+ * author reply 1409–1410`).
260
+ */
261
+ function formatPagesApa(pages) {
262
+ return pages.replace(PAGE_RANGE, (_, start, end) => `${start}\u{2013}${fullPageEnd(start, end)}`);
263
+ }
264
+ /**
265
+ * Words APA 7 keeps lowercase in a title unless one opens it or its subtitle:
266
+ * articles, coordinating conjunctions, and prepositions of three letters or
267
+ * fewer — plus the articles, conjunctions, and prepositions of the non-English
268
+ * journal names NLM catalogs, in NLM's unaccented spelling: Romance (`Annales
269
+ * de dermatologie et de venereologie`), Germanic and Scandinavian (`Beitrage
270
+ * zur gerichtlichen Medizin`), Slavic (`Akusherstvo i ginekologiia`), and the
271
+ * Hungarian, Turkish, Finnish, Japanese, and Latin ones NLM's titles use. A
272
+ * word that is also a romanized Chinese syllable stays out (`za` of `za zhi`,
273
+ * `ai`, `nei`), as does one NLM uses for an abbreviation (`ed.`, `med.`).
274
+ */
275
+ const APA_MINOR_WORDS = new Set([
276
+ ...['a', 'an', 'the', 'and', 'but', 'for', 'nor', 'or', 'so', 'yet'],
277
+ ...['as', 'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via'],
278
+ ...['de', 'des', 'du', 'la', 'le', 'les', 'et', 'sur', 'au', 'aux', 'pour', 'par', 'dans'],
279
+ ...['avec', 'un', 'une', 'el', 'los', 'las', 'y', 'e', 'al', 'para', 'por', 'o', 'os', 'em'],
280
+ ...['no', 'na', 'ao', 'da', 'das', 'do', 'dos', 'di', 'del', 'della', 'dello', 'delle', 'dei'],
281
+ ...['degli', 'alla', 'alle', 'fra', 'si', 'din', 'pentru', 'der', 'die', 'dem', 'den', 'und'],
282
+ ...['für', 'fur', 'zu', 'zur', 'im', 'am', 'auf', 'aus', 'mit', 'uber', 'van', 'von', 'voor'],
283
+ ...['en', 'het', 'tot', 'op', 'uit', 'te', 'og', 'och', 'af', 'til', 'fran', 'mot', 'vir'],
284
+ ...['i', 'v', 'w', 'z', 'u', 'po', 'pro', 'nad', 've', 'es', 'az', 'ja', 'ad'],
285
+ ]);
286
+ /** MLA 9 also keeps a preposition of any length lowercase. */
287
+ const MLA_MINOR_WORDS = new Set([
288
+ ...APA_MINOR_WORDS,
289
+ ...['about', 'above', 'across', 'against', 'along', 'among', 'around', 'behind', 'below'],
290
+ ...['beneath', 'beside', 'between', 'beyond', 'during', 'from', 'into', 'onto', 'over'],
291
+ ...['through', 'throughout', 'toward', 'towards', 'under', 'upon', 'with', 'within'],
292
+ ...['without'],
293
+ ]);
294
+ /** One hyphen-delimited part of a word: surrounding punctuation, then lowercase letters. */
295
+ const LOWERCASE_WORD = /^([^\p{L}\p{N}]*)(\p{Ll}+(?:['\u{2019}]\p{Ll}+)*)([^\p{L}\p{N}]*)$/u;
296
+ /** A French or Italian elided particle and the word it joins (`d'ophtalmologie`, `dell'apparato`). */
297
+ const ELISION = /^(d|l|qu|dell|dall|nell|all|sull)(['\u{2019}])(.+)$/u;
298
+ /** Brands NLM writes in lowercase, which keep it (`npj quantum materials`). */
299
+ const LOWERCASE_BRANDS = new Set(['npj']);
300
+ const capitalize = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
301
+ /**
302
+ * Title-case a journal name, which NLM catalogs in sentence case (`Frontiers in
303
+ * pediatrics`), for APA and MLA, which cite it in title case. Only a word part
304
+ * written entirely in lowercase letters gains a capital — each part of a
305
+ * hyphenated word on its own — so a name NLM already cases (`eLife`, `mBio`,
306
+ * `JAMA`, `PloS`) and any part carrying a digit stay as written, as do a
307
+ * lowercase brand and a single-letter prefix (`npj`, `i-Perception`, `e-SPEN`).
308
+ * An elision capitalizes the word, not its particle (`d'Ophtalmologie`). A minor
309
+ * word stays lowercase unless it opens the name, or a subtitle or parallel title
310
+ * after a colon, semicolon, or ` = ` (`CA: A Cancer Journal`). (#217)
311
+ */
312
+ function titleCaseJournal(title, minorWords) {
313
+ let opensPhrase = true;
314
+ const caseWord = (word, opens) => {
315
+ if (!opens && minorWords.has(word))
316
+ return word;
317
+ const elision = ELISION.exec(word);
318
+ if (!elision)
319
+ return capitalize(word);
320
+ const [, particle = '', apostrophe = '', rest = ''] = elision;
321
+ return `${opens ? capitalize(particle) : particle}${apostrophe}${caseWord(rest, false)}`;
322
+ };
323
+ return title
324
+ .split(/(\s+)/)
325
+ .map((token) => {
326
+ if (!/\S/.test(token))
327
+ return token;
328
+ if (token === ':' || token === ';' || token === '=') {
329
+ opensPhrase = true;
330
+ return token;
331
+ }
332
+ const parts = token.split('-');
333
+ const cased = parts
334
+ .map((part, index) => {
335
+ const match = LOWERCASE_WORD.exec(part);
336
+ if (!match)
337
+ return part;
338
+ const [, lead = '', word = '', trail = ''] = match;
339
+ const isPrefix = index === 0 && parts.length > 1 && word.length === 1;
340
+ if (isPrefix || LOWERCASE_BRANDS.has(word))
341
+ return part;
342
+ return `${lead}${caseWord(word, opensPhrase && index === 0)}${trail}`;
343
+ })
344
+ .join('-');
345
+ opensPhrase = /[:;]$/.test(token);
346
+ return cased;
347
+ })
348
+ .join('');
349
+ }
350
+ /**
351
+ * Author keywords, then MeSH descriptor names, each term once: a term equal to
352
+ * an earlier one ignoring case is dropped, so the author's spelling and position
353
+ * win (`Deep learning` over MeSH `Deep Learning`). (#217)
354
+ */
355
+ function mergedKeywords(article) {
356
+ const terms = new Map();
357
+ const candidates = [
358
+ ...(article.keywords ?? []),
359
+ ...(article.meshTerms ?? []).map((m) => m.descriptorName),
360
+ ];
361
+ for (const term of candidates) {
362
+ if (!term)
363
+ continue;
364
+ const key = term.toLowerCase();
365
+ if (!terms.has(key))
366
+ terms.set(key, term);
367
+ }
368
+ return [...terms.values()];
369
+ }
122
370
  /**
123
371
  * Collapse internal whitespace (including embedded newlines from structured
124
372
  * abstracts) to single spaces. Strict RIS parsers treat blank lines as record
@@ -314,7 +562,8 @@ function formatEditorsApa(editors) {
314
562
  * ```
315
563
  * Authors (Year). Chapter title. In E. Editor (Ed.), *Book title*. Publisher. URL
316
564
  * ```
317
- * A whole-book record drops the `In …` clause and italicizes its own title.
565
+ * A chapter's year is its own ({@link chapterDate}), not the book's. A whole-book
566
+ * record drops the `In …` clause and italicizes its own title.
318
567
  *
319
568
  * A record crediting nobody at all — no authors, and for a whole book no
320
569
  * editors either — moves its title into the author position instead (APA 7
@@ -354,8 +603,9 @@ function formatApaBook(article) {
354
603
  else if (book.title && !titleLedTheReference) {
355
604
  parts.push(`*${stripTrailingPeriod(book.title)}*.`);
356
605
  }
606
+ // A publisher already ending in a period (`MDText.com, Inc.`) gains no second one
357
607
  if (book.publisher)
358
- parts.push(`${book.publisher}.`);
608
+ parts.push(terminate(book.publisher));
359
609
  const url = bookAccessUrl(article);
360
610
  if (url)
361
611
  parts.push(url);
@@ -372,7 +622,8 @@ function formatApaBook(article) {
372
622
  * ```
373
623
  * Title. (Year). *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
374
624
  * ```
375
- * A Bookshelf record routes to {@link formatApaBook}.
625
+ * A retracted article ends on `(Retraction published <RefSource>)`. A Bookshelf
626
+ * record routes to {@link formatApaBook}.
376
627
  */
377
628
  export function formatApa(article) {
378
629
  if (isBookRecord(article))
@@ -403,21 +654,20 @@ export function formatApa(article) {
403
654
  // Journal, volume, issue, pages
404
655
  const journal = article.journalInfo;
405
656
  if (journal?.title) {
406
- let journalPart = `*${journal.title}*`;
657
+ let journalPart = `*${titleCaseJournal(journal.title, APA_MINOR_WORDS)}*`;
407
658
  if (journal.volume) {
408
659
  journalPart += `, *${journal.volume}*`;
409
660
  if (journal.issue) {
410
661
  journalPart += `(${journal.issue})`;
411
662
  }
412
663
  }
413
- if (journal.pages) {
414
- journalPart += `, ${journal.pages}`;
664
+ // APA 7 p. 294-295: an article number takes the page range's place
665
+ const number = articleNumber(journal);
666
+ if (number) {
667
+ journalPart += `, Article ${number}`;
415
668
  }
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}`;
669
+ else if (journal.pages) {
670
+ journalPart += `, ${formatPagesApa(journal.pages)}`;
421
671
  }
422
672
  journalPart += '.';
423
673
  parts.push(journalPart);
@@ -426,19 +676,20 @@ export function formatApa(article) {
426
676
  if (article.doi) {
427
677
  parts.push(`https://doi.org/${article.doi}`);
428
678
  }
679
+ // APA 7 notes a retraction after the DOI. Its own form names the notice's
680
+ // year, full journal title, and location, which `RefSource` does not carry
681
+ // apart; NCBI's exporter fills the parenthetical with `RefSource` instead.
682
+ const retractions = citationNotices(article)
683
+ .filter((notice) => notice.refType === 'RetractionIn')
684
+ .map((notice) => stripTrailingPeriod(notice.refSource));
685
+ if (retractions.length > 0) {
686
+ parts.push(`(Retraction published ${retractions.join('; ')})`);
687
+ }
429
688
  return parts.join(' ');
430
689
  }
431
690
  // ---------------------------------------------------------------------------
432
691
  // MLA 9th Edition
433
692
  // ---------------------------------------------------------------------------
434
- /**
435
- * Format a PubMed article as an MLA 9th edition citation.
436
- *
437
- * Pattern:
438
- * ```
439
- * Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
440
- * ```
441
- */
442
693
  /** MLA renders editors first-name-first after `edited by`. */
443
694
  function formatEditorsMla(editors) {
444
695
  const names = editors
@@ -458,9 +709,11 @@ function formatEditorsMla(editors) {
458
709
  * Format a Bookshelf record as an MLA 9th edition citation.
459
710
  *
460
711
  * ```
461
- * Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher, Year.
712
+ * Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher, Date.
462
713
  * ```
463
- * A whole-book record italicizes its own title in place of the quoted chapter.
714
+ * A chapter's date is its own, in full (`20 Dec. 2018`); otherwise the book's
715
+ * year. A whole-book record italicizes its own title in place of the quoted
716
+ * chapter.
464
717
  */
465
718
  function formatMlaBook(article) {
466
719
  const { book } = article;
@@ -481,13 +734,23 @@ function formatMlaBook(article) {
481
734
  detailParts.push(book.edition);
482
735
  if (book.publisher)
483
736
  detailParts.push(book.publisher);
484
- const year = getYear(article);
485
- if (year !== 'n.d.')
486
- detailParts.push(year);
737
+ const ownDate = chapterDate(article);
738
+ const date = ownDate ? formatDateMla(ownDate) : getYear(article);
739
+ if (date !== 'n.d.')
740
+ detailParts.push(date);
487
741
  if (detailParts.length)
488
- parts.push(`${detailParts.join(', ')}.`);
742
+ parts.push(terminate(detailParts.join(', ')));
489
743
  return parts.join(' ');
490
744
  }
745
+ /**
746
+ * Format a PubMed article as an MLA 9th edition citation.
747
+ *
748
+ * Pattern:
749
+ * ```
750
+ * Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
751
+ * ```
752
+ * A Bookshelf record routes to {@link formatMlaBook}.
753
+ */
491
754
  export function formatMla(article) {
492
755
  if (isBookRecord(article))
493
756
  return formatMlaBook(article);
@@ -507,7 +770,7 @@ export function formatMla(article) {
507
770
  const journal = article.journalInfo;
508
771
  if (journal?.title) {
509
772
  const detailParts = [];
510
- detailParts.push(`*${journal.title}*`);
773
+ detailParts.push(`*${titleCaseJournal(journal.title, MLA_MINOR_WORDS)}*`);
511
774
  if (journal.volume) {
512
775
  detailParts.push(`vol. ${journal.volume}`);
513
776
  }
@@ -518,18 +781,17 @@ export function formatMla(article) {
518
781
  if (year !== 'n.d.') {
519
782
  detailParts.push(year);
520
783
  }
521
- if (journal.pages) {
784
+ // MLA 9 codifies no article-number form; citation guides converge on
785
+ // "art. <value>" in the page position.
786
+ const number = articleNumber(journal);
787
+ if (number) {
788
+ detailParts.push(`art. ${number}`);
789
+ }
790
+ else if (journal.pages) {
522
791
  // MLA 9 §6.56: "p." for a single page, "pp." for a range
523
792
  const isRange = /[-\u2013\u2014]/.test(journal.pages);
524
793
  detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
525
794
  }
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
- }
533
795
  parts.push(`${detailParts.join(', ')}.`);
534
796
  }
535
797
  // DOI
@@ -650,20 +912,13 @@ export function formatBibtex(article) {
650
912
  if (url)
651
913
  fields.push(['url', url]);
652
914
  }
653
- // Keywords — merge article keywords with MeSH descriptor names
654
- const keywordSet = new Set();
655
- for (const k of article.keywords ?? [])
656
- keywordSet.add(k);
657
- for (const m of article.meshTerms ?? []) {
658
- if (m.descriptorName)
659
- keywordSet.add(m.descriptorName);
660
- }
661
- // Brace-wrap each term so MeSH descriptors that carry internal commas in
662
- // their inverted form (e.g. "Databases, Protein") stay a single keyword —
663
- // biblatex's comma-separated list parser reads a braced item as one element.
664
- if (keywordSet.size > 0) {
665
- const keywords = [...keywordSet].map((k) => `{${escapeBibtex(k)}}`).join(', ');
666
- fields.push(['keywords', keywords]);
915
+ // Keywords — article keywords merged with MeSH descriptor names. Brace-wrap
916
+ // each term so MeSH descriptors that carry internal commas in their inverted
917
+ // form (e.g. "Databases, Protein") stay a single keyword — biblatex's
918
+ // comma-separated list parser reads a braced item as one element.
919
+ const keywords = mergedKeywords(article);
920
+ if (keywords.length > 0) {
921
+ fields.push(['keywords', keywords.map((k) => `{${escapeBibtex(k)}}`).join(', ')]);
667
922
  }
668
923
  // Build entry
669
924
  const maxKeyLen = Math.max(...fields.map(([k]) => k.length));
@@ -735,11 +990,14 @@ export function formatRis(article) {
735
990
  tag('ET', book.edition);
736
991
  tag('T3', book.collectionTitle);
737
992
  }
738
- // Year
993
+ // Year, and a Bookshelf chapter's own date in full as `DA` (YYYY/MM/DD)
739
994
  const year = getYear(article);
740
995
  if (year !== 'n.d.') {
741
996
  tag('PY', year);
742
997
  }
998
+ const ownDate = chapterDate(article);
999
+ if (ownDate)
1000
+ tag('DA', formatDateRis(ownDate));
743
1001
  // Volume & Issue
744
1002
  tag('VL', journal?.volume);
745
1003
  tag('IS', journal?.issue);
@@ -775,15 +1033,8 @@ export function formatRis(article) {
775
1033
  if (url)
776
1034
  lines.push(`UR - ${url}`);
777
1035
  }
778
- // Keywords — merge article keywords with MeSH descriptor names
779
- const keywordSet = new Set();
780
- for (const k of article.keywords ?? [])
781
- keywordSet.add(k);
782
- for (const m of article.meshTerms ?? []) {
783
- if (m.descriptorName)
784
- keywordSet.add(m.descriptorName);
785
- }
786
- for (const kw of keywordSet) {
1036
+ // Keywords — article keywords merged with MeSH descriptor names
1037
+ for (const kw of mergedKeywords(article)) {
787
1038
  tag('KW', kw);
788
1039
  }
789
1040
  // Abstract — collapse internal whitespace so blank lines don't break strict
@@ -838,30 +1089,18 @@ function formatAuthorsVancouver(authors) {
838
1089
  return names.join(', ');
839
1090
  return `${names.slice(0, 6).join(', ')}, et al.`;
840
1091
  }
841
- /**
842
- * Format a PubMed article as a Vancouver (ICMJE/NLM) reference — the numbered
843
- * style used by NEJM, Lancet, JAMA, BMJ, and most biomedical journals.
844
- *
845
- * Pattern (the leading list number is the consumer's responsibility — entries
846
- * are formatted independently, so this returns the unnumbered reference body):
847
- * ```
848
- * Surname AB, Surname CD, et al. Article title. Abbrev J Name. Year;Vol(Issue):Pages. doi: 10.x/y
849
- * ```
850
- * Journal name uses the NLM/ISO abbreviation when available; pages are used as
851
- * PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
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.`).
854
- */
855
1092
  /**
856
1093
  * Format a Bookshelf record as a Vancouver (NLM) reference, following *Citing
857
1094
  * Medicine* 2e Ch. 22 §C, Contributions to Books on the Internet:
858
1095
  * ```
859
- * Authors. Chapter title. In: Editors, editors. Book title [Internet].
860
- * Place: Publisher; date. Available from: URL
1096
+ * Authors. Chapter title. Contributed [updated Revised]. In: Editors, editors.
1097
+ * Book title [Internet]. Place: Publisher; date. Available from: URL
861
1098
  * ```
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
1099
+ * The chapter carries its own dates (`1998 Oct 23 [updated 2018 Dec 20]`); the
1100
+ * container keeps the book's, open-ended as `2000-` while the book is still
1101
+ * updated. A whole-book record drops the contribution and the `In:`, taking the
1102
+ * book title as its own. `[cited …]` and the extent (`[about 41 p.]`) are part of
1103
+ * the NLM pattern but neither is derivable from an EFetch record, so both are
865
1104
  * omitted rather than invented. Editors stand in for absent authors on a whole
866
1105
  * book, which is the NLM form for an edited work.
867
1106
  */
@@ -875,6 +1114,13 @@ function formatVancouverBook(article) {
875
1114
  if (article.recordType === 'book-chapter') {
876
1115
  if (article.title)
877
1116
  segments.push(`${stripTrailingPeriod(article.title)}.`);
1117
+ const { contributed, revised } = chapterDates(article);
1118
+ const dated = [
1119
+ contributed && formatDateNlm(contributed),
1120
+ revised && `[updated ${formatDateNlm(revised)}]`,
1121
+ ].filter(Boolean);
1122
+ if (dated.length)
1123
+ segments.push(`${dated.join(' ')}.`);
878
1124
  segments.push(editorStr ? `In: ${editorStr}, editors.` : 'In:');
879
1125
  }
880
1126
  else if (!authorStr && editorStr) {
@@ -885,13 +1131,37 @@ function formatVancouverBook(article) {
885
1131
  segments.push(`${stripTrailingPeriod(container)}.`);
886
1132
  const source = [bookImprint(book), bookDateSpan(book)].filter(Boolean).join('; ');
887
1133
  if (source)
888
- segments.push(`${source}.`);
1134
+ segments.push(terminate(source));
889
1135
  // No trailing period — it would be read as part of the URL
890
1136
  const url = bookshelfUrl(book);
891
1137
  if (url)
892
1138
  segments.push(`Available from: ${url}`);
893
1139
  return segments.join(' ');
894
1140
  }
1141
+ /**
1142
+ * The notices Vancouver notes after the reference, each with Citing Medicine's
1143
+ * label (Boxes 58–59). NCBI's own NLM export prints these two and no others.
1144
+ */
1145
+ const VANCOUVER_NOTE_LABELS = {
1146
+ RetractionIn: 'Retraction in',
1147
+ ErratumIn: 'Erratum in',
1148
+ };
1149
+ /**
1150
+ * Format a PubMed article as a Vancouver (ICMJE/NLM) reference — the numbered
1151
+ * style used by NEJM, Lancet, JAMA, BMJ, and most biomedical journals.
1152
+ *
1153
+ * Pattern (the leading list number is the consumer's responsibility — entries
1154
+ * are formatted independently, so this returns the unnumbered reference body):
1155
+ * ```
1156
+ * Surname AB, Surname CD, et al. Article title. Abbrev J Name. Year;Vol(Issue):Pages. doi: 10.x/y
1157
+ * ```
1158
+ * Journal name uses the NLM/ISO abbreviation when available; pages are used as
1159
+ * PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
1160
+ * period so it stays copy-pasteable, unless a note follows it. An article number replaces nothing — with
1161
+ * no pagination it trails the source as an NLM note (`. pii: 2400512.`). A
1162
+ * retraction or erratum follows as a note (`Retraction in: <RefSource>`). A
1163
+ * Bookshelf record routes to {@link formatVancouverBook}.
1164
+ */
895
1165
  export function formatVancouver(article) {
896
1166
  if (isBookRecord(article))
897
1167
  return formatVancouverBook(article);
@@ -934,10 +1204,19 @@ export function formatVancouver(article) {
934
1204
  const label = journal?.elocationIdType;
935
1205
  segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
936
1206
  }
937
- // DOI — NLM "doi: <doi>" form; no trailing period (would corrupt the DOI)
1207
+ // Citing Medicine's retraction and erratum notes (Boxes 58–59), one per
1208
+ // notice in NCBI's order with `RefSource` verbatim, as NCBI's NLM export
1209
+ // prints them. Each ends on exactly one period: some `RefSource`s carry none.
1210
+ const notes = citationNotices(article).flatMap((notice) => {
1211
+ const label = VANCOUVER_NOTE_LABELS[notice.refType];
1212
+ return label ? [terminate(`${label}: ${notice.refSource}`)] : [];
1213
+ });
1214
+ // DOI — NLM "doi: <doi>" form with no trailing period, which a reader copying
1215
+ // it would take as part of the DOI; a note after it closes it, as in NLM's export
938
1216
  if (article.doi) {
939
- segments.push(`doi: ${article.doi}`);
1217
+ segments.push(notes.length > 0 ? `doi: ${article.doi}.` : `doi: ${article.doi}`);
940
1218
  }
1219
+ segments.push(...notes);
941
1220
  return segments.join(' ');
942
1221
  }
943
1222
  // ---------------------------------------------------------------------------