@cyanheads/pubmed-mcp-server 2.10.20 → 2.10.22

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 +3 -3
  2. package/CLAUDE.md +3 -3
  3. package/Dockerfile +3 -3
  4. package/README.md +14 -11
  5. package/changelog/2.10.x/2.10.21.md +42 -0
  6. package/changelog/2.10.x/2.10.22.md +44 -0
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/_budget.d.ts +35 -6
  10. package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/_budget.js +26 -3
  12. package/dist/mcp-server/tools/definitions/_budget.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/_notices.d.ts +33 -0
  14. package/dist/mcp-server/tools/definitions/_notices.d.ts.map +1 -0
  15. package/dist/mcp-server/tools/definitions/_notices.js +35 -0
  16. package/dist/mcp-server/tools/definitions/_notices.js.map +1 -0
  17. package/dist/mcp-server/tools/definitions/_schemas.d.ts +14 -0
  18. package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/_schemas.js +9 -0
  20. package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +11 -0
  22. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +64 -19
  24. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +22 -4
  26. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +386 -167
  28. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
  30. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/format-citations.tool.js +41 -8
  32. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +8 -6
  35. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +1 -1
  37. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +66 -76
  39. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +6 -5
  41. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +11 -18
  43. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -4
  45. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +9 -8
  47. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  48. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +4 -4
  49. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/definitions/spell-check.tool.js +12 -8
  51. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  52. package/dist/services/error-contracts.d.ts +1 -1
  53. package/dist/services/error-contracts.d.ts.map +1 -1
  54. package/dist/services/error-contracts.js +1 -1
  55. package/dist/services/error-contracts.js.map +1 -1
  56. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  57. package/dist/services/europe-pmc/api-client.js +4 -3
  58. package/dist/services/europe-pmc/api-client.js.map +1 -1
  59. package/dist/services/europe-pmc/europe-pmc-service.d.ts +31 -11
  60. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  61. package/dist/services/europe-pmc/europe-pmc-service.js +122 -47
  62. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  63. package/dist/services/europe-pmc/types.d.ts +5 -1
  64. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  65. package/dist/services/fetch-redaction.d.ts +23 -0
  66. package/dist/services/fetch-redaction.d.ts.map +1 -0
  67. package/dist/services/fetch-redaction.js +62 -0
  68. package/dist/services/fetch-redaction.js.map +1 -0
  69. package/dist/services/ncbi/api-client.d.ts +3 -0
  70. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  71. package/dist/services/ncbi/api-client.js +21 -8
  72. package/dist/services/ncbi/api-client.js.map +1 -1
  73. package/dist/services/ncbi/formatting/citation-formatter.d.ts +19 -3
  74. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  75. package/dist/services/ncbi/formatting/citation-formatter.js +410 -138
  76. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  77. package/dist/services/ncbi/ncbi-service.d.ts +22 -0
  78. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  79. package/dist/services/ncbi/ncbi-service.js +115 -18
  80. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  81. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  82. package/dist/services/ncbi/parsing/article-parser.js +89 -36
  83. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  84. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +13 -8
  85. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  86. package/dist/services/ncbi/parsing/pmc-article-parser.js +326 -72
  87. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  88. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +42 -0
  89. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  90. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +275 -12
  91. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  92. package/dist/services/ncbi/parsing/text-helpers.d.ts +10 -0
  93. package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
  94. package/dist/services/ncbi/parsing/text-helpers.js +19 -0
  95. package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
  96. package/dist/services/ncbi/response-handler.d.ts +0 -11
  97. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  98. package/dist/services/ncbi/response-handler.js +24 -33
  99. package/dist/services/ncbi/response-handler.js.map +1 -1
  100. package/dist/services/ncbi/types.d.ts +72 -32
  101. package/dist/services/ncbi/types.d.ts.map +1 -1
  102. package/dist/services/openalex/api-client.d.ts.map +1 -1
  103. package/dist/services/openalex/api-client.js +4 -3
  104. package/dist/services/openalex/api-client.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 +7 -7
  110. package/server.json +3 -3
@@ -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
@@ -171,30 +192,13 @@ function terminate(text) {
171
192
  return text.endsWith('.') ? text : `${text}.`;
172
193
  }
173
194
  /**
174
- * Split a pages string like "45-67" into start and end components.
175
- * Handles en-dashes, em-dashes, and hyphens. Expands PubMed's truncated-end
176
- * convention (e.g., "737-8" → { start: "737", end: "738" }, "1639-41" →
177
- * "1639"/"1641") so downstream RIS/BibTeX consumers see absolute page numbers.
178
- */
179
- function splitPages(pages) {
180
- if (!pages)
181
- return {};
182
- const parts = pages.split(/[-\u2013\u2014]/).map((p) => p.trim());
183
- let [start, end] = parts;
184
- if (start && end && end.length < start.length) {
185
- end = start.slice(0, start.length - end.length) + end;
186
- }
187
- if (start && end)
188
- return { start, end };
189
- return start ? { start } : {};
190
- }
191
- /**
192
- * The electronic article locator that stands in for a page range, or undefined.
195
+ * The electronic article locator that Vancouver, BibTeX, and RIS print in place
196
+ * of a page range, or undefined.
193
197
  *
194
198
  * Returns a value only when the record carries no pagination. Publishers that
195
199
  * report the article number as both `Pagination` and `ELocationID` (PLoS ONE,
196
200
  * Scientific Reports) are already covered by the `pages` rendering, and printing
197
- * both would duplicate the number in every style.
201
+ * both would duplicate the number. APA and MLA use {@link articleNumber}.
198
202
  */
199
203
  function articleLocator(journal) {
200
204
  if (journal?.pages)
@@ -202,9 +206,256 @@ function articleLocator(journal) {
202
206
  return journal?.elocationId || undefined;
203
207
  }
204
208
  /**
205
- * Collapse internal whitespace (including embedded newlines from structured
206
- * abstracts) to single spaces. Strict RIS parsers treat blank lines as record
207
- * terminators, so abstract text must be flattened before emission.
209
+ * The article number APA and MLA cite in place of a page range: the record's
210
+ * non-DOI locator when it has no pagination, or when its pagination is that
211
+ * same value — NCBI copies an article number into `MedlinePgn` (Frontiers
212
+ * `662953`, Springer `42`). A page that matches no locator stays a page, though
213
+ * some are article numbers the record cannot identify (an Elsevier `105471`
214
+ * beside an `S…` pii looks exactly like a Lancet page `2413`). (#217)
215
+ *
216
+ * A dashless `MedlinePgn` also matches the locator with its hyphens removed:
217
+ * American Society for Microbiology journals deposit `e0364621` beside pii
218
+ * `e03646-21`. The locator's form is the one cited, as the publisher numbers
219
+ * the article. Pagination holding a dash is a range and never matches. (#231)
220
+ */
221
+ function articleNumber(journal) {
222
+ const locator = journal?.elocationId;
223
+ if (!locator)
224
+ return;
225
+ const { pages } = journal;
226
+ if (!pages || pages === locator)
227
+ return locator;
228
+ return !PAGE_DASH.test(pages) && pages === locator.replaceAll('-', '') ? locator : undefined;
229
+ }
230
+ /** A dash NLM's pagination puts between the ends of a range. */
231
+ const PAGE_DASH = /[-\u{2013}\u{2014}]/u;
232
+ /**
233
+ * One page or range inside NLM's pagination: a start, then optionally a dash
234
+ * and an end. An item starts only after a separator, so a long run without a
235
+ * dash is read once rather than retried from each of its characters.
236
+ */
237
+ const PAGE_ITEM = /(?<![^\s,;\u{2013}\u{2014}-])([^\s,;\u{2013}\u{2014}-]+)(?:\s*[-\u{2013}\u{2014}]\s*([^\s,;\u{2013}\u{2014}-]+))?/gu;
238
+ /** The digits a page ends on, matched from the start of their run only. */
239
+ const TRAILING_DIGITS = /(?<!\d)\d+$/;
240
+ /**
241
+ * The end of a range as NLM's elision means it. An end made of digits is
242
+ * written against the start's digits and takes the start's prefix (`637-41` →
243
+ * `641`, `S5-15` → `S15`, `1255.e1-5` → `1255.e5`); any other end is already
244
+ * whole (`878.e8`, `e65`, `xv`).
245
+ */
246
+ function fullPageEnd(start, end) {
247
+ const digits = /^\d+$/.test(end) ? TRAILING_DIGITS.exec(start) : null;
248
+ if (!digits)
249
+ return end;
250
+ const [run] = digits;
251
+ const elided = end.length < run.length;
252
+ return `${start.slice(0, digits.index)}${elided ? run.slice(0, run.length - end.length) : ''}${end}`;
253
+ }
254
+ /**
255
+ * NLM's pagination with each range written in full (`637-41` → `637-641`,
256
+ * `S5-15` → `S5-S15`), joined by `dash` or by NLM's own when none is given; the
257
+ * rest of NLM's text stays as given — a list's separators and labels too
258
+ * (`1407-8; author reply 1409-10` → `1407-1408; author reply 1409-1410`). APA
259
+ * joins with an en dash (`637–641`); BibTeX keeps NLM's. (#217, #228)
260
+ */
261
+ function expandPageRanges(pages, dash) {
262
+ return pages.replace(PAGE_ITEM, (item, start, end) => {
263
+ if (end === undefined)
264
+ return item;
265
+ const join = dash ?? item.slice(start.length, item.length - end.length);
266
+ return `${start}${join}${fullPageEnd(start, end)}`;
267
+ });
268
+ }
269
+ /** A token that can be a page: it carries a digit or is a Roman numeral, unlike a label (`author reply`). */
270
+ const PAGE_TOKEN = /\d|^[ivxlcdm]+$/i;
271
+ /**
272
+ * The first and last page of NLM's pagination, for RIS `SP` and `EP`: the start
273
+ * of the first page or range, and the end of the last range written in full or
274
+ * the last listed page (`1308, 1310-4` → `1308`/`1314`, `xii-xv` → `xii`/`xv`,
275
+ * `S5-15` → `S5`/`S15`). A single page or article number has no end, and a
276
+ * label in a list (`author reply`) is not a page. NCBI's own `StartPage` and
277
+ * `EndPage` agree on every shape. (#228)
278
+ */
279
+ function splitPages(pages) {
280
+ const items = [...pages.matchAll(PAGE_ITEM)];
281
+ const [, start] = items[0] ?? [];
282
+ const pageItems = items.filter(([, first = '', last]) => last !== undefined || PAGE_TOKEN.test(first));
283
+ const final = pageItems.at(-1);
284
+ if (!final || (pageItems.length === 1 && final[2] === undefined)) {
285
+ return { start, end: undefined };
286
+ }
287
+ const [, first = '', last] = final;
288
+ return { start, end: last === undefined ? first : fullPageEnd(first, last) };
289
+ }
290
+ /**
291
+ * Words APA 7 keeps lowercase in a title unless one opens it or its subtitle:
292
+ * articles, coordinating conjunctions, and prepositions of three letters or
293
+ * fewer — plus the articles, conjunctions, and prepositions of the non-English
294
+ * journal names NLM catalogs, in NLM's unaccented spelling: Romance (`Annales
295
+ * de dermatologie et de venereologie`), Germanic and Scandinavian (`Beitrage
296
+ * zur gerichtlichen Medizin`), Slavic (`Akusherstvo i ginekologiia`), and the
297
+ * Hungarian, Turkish, Finnish, Japanese, and Latin ones NLM's titles use. A
298
+ * word that is also a romanized Chinese syllable stays out (`za` of `za zhi`,
299
+ * `ai`, `nei`), as does one NLM uses for an abbreviation (`ed.`, `med.`).
300
+ */
301
+ const APA_MINOR_WORDS = new Set([
302
+ ...['a', 'an', 'the', 'and', 'but', 'for', 'nor', 'or', 'so', 'yet'],
303
+ ...['as', 'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via'],
304
+ ...['de', 'des', 'du', 'la', 'le', 'les', 'et', 'sur', 'au', 'aux', 'pour', 'par', 'dans'],
305
+ ...['avec', 'un', 'une', 'el', 'los', 'las', 'y', 'e', 'al', 'para', 'por', 'o', 'os', 'em'],
306
+ ...['no', 'na', 'ao', 'da', 'das', 'do', 'dos', 'di', 'del', 'della', 'dello', 'delle', 'dei'],
307
+ ...['degli', 'alla', 'alle', 'fra', 'si', 'din', 'pentru', 'der', 'die', 'dem', 'den', 'und'],
308
+ ...['für', 'fur', 'zu', 'zur', 'im', 'am', 'auf', 'aus', 'mit', 'uber', 'van', 'von', 'voor'],
309
+ ...['en', 'het', 'tot', 'op', 'uit', 'te', 'og', 'och', 'af', 'til', 'fran', 'mot', 'vir'],
310
+ ...['i', 'v', 'w', 'z', 'u', 'po', 'pro', 'nad', 've', 'es', 'az', 'ja', 'ad'],
311
+ ]);
312
+ /** MLA 9 also keeps a preposition of any length lowercase. */
313
+ const MLA_MINOR_WORDS = new Set([
314
+ ...APA_MINOR_WORDS,
315
+ ...['about', 'above', 'across', 'against', 'along', 'among', 'around', 'behind', 'below'],
316
+ ...['beneath', 'beside', 'between', 'beyond', 'during', 'from', 'into', 'onto', 'over'],
317
+ ...['through', 'throughout', 'toward', 'towards', 'under', 'upon', 'with', 'within'],
318
+ ...['without'],
319
+ ]);
320
+ /** One hyphen-delimited part of a word: surrounding punctuation, then lowercase letters. */
321
+ const LOWERCASE_WORD = /^([^\p{L}\p{N}]*)(\p{Ll}+(?:['\u{2019}]\p{Ll}+)*)([^\p{L}\p{N}]*)$/u;
322
+ /** A French or Italian elided particle and the word it joins (`d'ophtalmologie`, `dell'apparato`). */
323
+ const ELISION = /^(d|l|qu|dell|dall|nell|all|sull)(['\u{2019}])(.+)$/u;
324
+ /** Brands NLM writes in lowercase, which keep it (`npj quantum materials`). */
325
+ const LOWERCASE_BRANDS = new Set(['npj']);
326
+ const capitalize = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
327
+ /**
328
+ * Title-case a journal name, which NLM catalogs in sentence case (`Frontiers in
329
+ * pediatrics`), for APA and MLA, which cite it in title case. Only a word part
330
+ * written entirely in lowercase letters gains a capital — each part of a
331
+ * hyphenated word on its own — so a name NLM already cases (`eLife`, `mBio`,
332
+ * `JAMA`, `PloS`) and any part carrying a digit stay as written, as do a
333
+ * lowercase brand and a single-letter prefix (`npj`, `i-Perception`, `e-SPEN`).
334
+ * An elision capitalizes the word, not its particle (`d'Ophtalmologie`). A minor
335
+ * word stays lowercase unless it opens the name, or a subtitle or parallel title
336
+ * after a colon, semicolon, or ` = ` (`CA: A Cancer Journal`). (#217)
337
+ */
338
+ function titleCaseJournal(title, minorWords) {
339
+ let opensPhrase = true;
340
+ const caseWord = (word, opens) => {
341
+ if (!opens && minorWords.has(word))
342
+ return word;
343
+ const elision = ELISION.exec(word);
344
+ if (!elision)
345
+ return capitalize(word);
346
+ const [, particle = '', apostrophe = '', rest = ''] = elision;
347
+ return `${opens ? capitalize(particle) : particle}${apostrophe}${caseWord(rest, false)}`;
348
+ };
349
+ return title
350
+ .split(/(\s+)/)
351
+ .map((token) => {
352
+ if (!/\S/.test(token))
353
+ return token;
354
+ if (token === ':' || token === ';' || token === '=') {
355
+ opensPhrase = true;
356
+ return token;
357
+ }
358
+ const parts = token.split('-');
359
+ const cased = parts
360
+ .map((part, index) => {
361
+ const match = LOWERCASE_WORD.exec(part);
362
+ if (!match)
363
+ return part;
364
+ const [, lead = '', word = '', trail = ''] = match;
365
+ const isPrefix = index === 0 && parts.length > 1 && word.length === 1;
366
+ if (isPrefix || LOWERCASE_BRANDS.has(word))
367
+ return part;
368
+ return `${lead}${caseWord(word, opensPhrase && index === 0)}${trail}`;
369
+ })
370
+ .join('-');
371
+ opensPhrase = /[:;]$/.test(token);
372
+ return cased;
373
+ })
374
+ .join('');
375
+ }
376
+ /** NLM's year qualifier: `Journal of women's health (2002)`. */
377
+ const YEAR_QUALIFIER = /^\d{4}$/;
378
+ /** An edition or format qualifier: `(Clinical research ed.)`, `(Landmark edition)`, `(Print)`. */
379
+ const EDITION_QUALIFIER = /(?:\bed\.|\bedition)$|(?:^|: )(?:Online|Print)$/i;
380
+ /** An abbreviation naming an edition: `Med Clin (Engl Ed)`, `Angew Chem Int Ed Engl`. */
381
+ const EDITION_ABBREVIATION = /\b(?:Ed|Edn|Edition)\b/i;
382
+ /**
383
+ * A place qualifier, `Place, Region` with an optional year: `London, England`,
384
+ * `New York, N.Y.`, `Chicago, Ill. : 1978`. Neither part holds a parenthesis,
385
+ * semicolon, or digit, and the region absorbs any space before the year's
386
+ * colon, so no two parts compete for a character and a near-miss fails in
387
+ * linear time.
388
+ */
389
+ const PLACE_QUALIFIER = /^[^,():;\d]+,[^():;\d]+(?::\s*\d{4})?$/;
390
+ /**
391
+ * The parenthetical a title ends on, a nested one read as one unit
392
+ * (`Bulletin (Hospital for Joint Diseases (New York, N.Y.))`), with the text
393
+ * before it; undefined when the title does not end on a balanced one.
394
+ */
395
+ function trailingParenthetical(title) {
396
+ if (!title.endsWith(')'))
397
+ return;
398
+ let depth = 0;
399
+ for (let i = title.length - 1; i >= 0; i--) {
400
+ if (title[i] === ')')
401
+ depth++;
402
+ else if (title[i] === '(' && --depth === 0) {
403
+ return { base: title.slice(0, i).trimEnd(), inner: title.slice(i + 1, -1) };
404
+ }
405
+ }
406
+ return;
407
+ }
408
+ /**
409
+ * The journal name APA and MLA cite: NLM's title less the place, year, or
410
+ * edition NLM appends to tell same-named journals apart (`Lancet (London,
411
+ * England)` → `Lancet`, `Journal of women's health (2002)`, `BMJ (Clinical
412
+ * research ed.)`). The journal prints no such text. An edition stays when the
413
+ * ISO abbreviation names it too, as it is then a journal of its own (`Medicina
414
+ * clinica (English ed.)`, `Med Clin (Engl Ed)`). Any other parenthetical names
415
+ * the journal and stays: an issuing body (`Proceedings (Baylor University.
416
+ * Medical Center)`), a subject, a one-word place. Over NLM's journal list the
417
+ * rule is wrong on 18 of the 2,005 titles NLM's catalog settles — a place-shaped
418
+ * issuing body (`Collegian (Royal College of Nursing, Australia)`) is dropped,
419
+ * and a place with no region (`Medical record (New York : 1934)`) is kept. (#227)
420
+ */
421
+ function citedJournalName(title, isoAbbreviation) {
422
+ const group = trailingParenthetical(title);
423
+ if (!group?.base)
424
+ return title;
425
+ const { base, inner } = group;
426
+ if (YEAR_QUALIFIER.test(inner))
427
+ return base;
428
+ if (EDITION_QUALIFIER.test(inner)) {
429
+ return EDITION_ABBREVIATION.test(isoAbbreviation ?? '') ? title : base;
430
+ }
431
+ return PLACE_QUALIFIER.test(inner) ? base : title;
432
+ }
433
+ /**
434
+ * Author keywords, then MeSH descriptor names, each term once: a term equal to
435
+ * an earlier one ignoring case is dropped, so the author's spelling and position
436
+ * win (`Deep learning` over MeSH `Deep Learning`). (#217)
437
+ */
438
+ function mergedKeywords(article) {
439
+ const terms = new Map();
440
+ const candidates = [
441
+ ...(article.keywords ?? []),
442
+ ...(article.meshTerms ?? []).map((m) => m.descriptorName),
443
+ ];
444
+ for (const term of candidates) {
445
+ if (!term)
446
+ continue;
447
+ const key = term.toLowerCase();
448
+ if (!terms.has(key))
449
+ terms.set(key, term);
450
+ }
451
+ return [...terms.values()];
452
+ }
453
+ /**
454
+ * Collapse each whitespace run to one space and trim the ends. The parser trims
455
+ * only a field's ends, so a line break inside a title, name, or keyword — or
456
+ * between a structured abstract's paragraphs — would otherwise reach a
457
+ * citation: an untagged RIS line, or a blank one that strict parsers read as
458
+ * the end of the record. Every style writes record text through this. (#233)
208
459
  */
209
460
  function collapseWhitespace(text) {
210
461
  return text.replace(/\s+/g, ' ').trim();
@@ -254,8 +505,23 @@ function escapeBibtex(text) {
254
505
  // ---------------------------------------------------------------------------
255
506
  // Author formatters
256
507
  // ---------------------------------------------------------------------------
508
+ /*
509
+ * A generational suffix (`Jr`, `2nd`) renders verbatim as NCBI writes it, as
510
+ * NCBI's own citation export prints it. It qualifies a surname, so a name with
511
+ * no surname renders without it. Inverted name forms set it off with a comma
512
+ * (`Frenck, R. W., Jr`); natural-order and NLM forms append it after a space
513
+ * (`R. W. Frenck Jr`, `Frenck RW Jr`). (#226)
514
+ */
515
+ /** `, Jr` for an inverted name form; `''` without a suffix or a surname. */
516
+ function invertedSuffix(author) {
517
+ return author.lastName && author.suffix ? `, ${author.suffix}` : '';
518
+ }
519
+ /** ` Jr` for a natural-order or NLM name form; `''` without a suffix or a surname. */
520
+ function trailingSuffix(author) {
521
+ return author.lastName && author.suffix ? ` ${author.suffix}` : '';
522
+ }
257
523
  /**
258
- * Format a single author in APA style: `Last, F. M.`
524
+ * Format a single author in APA style: `Last, F. M.`, or `Last, F. M., Jr`.
259
525
  * Collective/group authors return the group name directly.
260
526
  */
261
527
  function formatAuthorApa(author) {
@@ -270,7 +536,7 @@ function formatAuthorApa(author) {
270
536
  .map((part) => `${part[0]}.`)
271
537
  .join(' ');
272
538
  if (!initials)
273
- return last;
539
+ return `${last}${invertedSuffix(author)}`;
274
540
  // Extract only letter characters (Unicode-aware: preserve Á, Ö, É, etc.),
275
541
  // format each as "X." separated by spaces.
276
542
  const formatted = Array.from(initials.replace(/[^\p{L}]/gu, ''))
@@ -278,7 +544,7 @@ function formatAuthorApa(author) {
278
544
  .join(' ');
279
545
  if (!last)
280
546
  return formatted;
281
- return `${last}, ${formatted}`;
547
+ return `${last}, ${formatted}${invertedSuffix(author)}`;
282
548
  }
283
549
  /**
284
550
  * Format the full author list for APA 7th edition.
@@ -305,21 +571,19 @@ function formatAuthorsApa(authors) {
305
571
  }
306
572
  /**
307
573
  * Format a single author in MLA style.
308
- * First listed author: `Last, First Middle.`
309
- * Subsequent authors: `First Middle Last`
574
+ * First listed author: `Last, First Middle`, or `Last, First Middle, Jr`.
575
+ * Subsequent authors: `First Middle Last`, or `First Middle Last Jr`.
310
576
  */
311
577
  function formatAuthorMla(author, isFirst) {
312
578
  if (author.collectiveName)
313
579
  return author.collectiveName;
314
580
  const last = author.lastName ?? '';
315
581
  const first = author.firstName ?? '';
316
- if (!last && !first)
317
- return '';
318
- if (!first)
319
- return last;
320
582
  if (!last)
321
583
  return first;
322
- return isFirst ? `${last}, ${first}` : `${first} ${last}`;
584
+ if (isFirst)
585
+ return `${first ? `${last}, ${first}` : last}${invertedSuffix(author)}`;
586
+ return `${first ? `${first} ${last}` : last}${trailingSuffix(author)}`;
323
587
  }
324
588
  /**
325
589
  * Format the full author list for MLA 9th edition.
@@ -342,27 +606,28 @@ function formatAuthorsMla(authors) {
342
606
  return `${formatAuthorMla(first, true)}, et al.`;
343
607
  }
344
608
  /**
345
- * Format a single author in BibTeX style: `{Last}, {First}`
609
+ * Format a single author in BibTeX style: `{Last}, First`, or the three-part
610
+ * `{Last}, Jr, First` when the name carries a suffix — the comma after `Jr`
611
+ * stays even with no first name, so the suffix is never read as one.
346
612
  */
347
613
  function formatAuthorBibtex(author) {
348
614
  if (author.collectiveName)
349
615
  return `{${escapeBibtex(author.collectiveName)}}`;
350
616
  const last = author.lastName ? escapeBibtex(author.lastName) : '';
351
617
  const first = author.firstName ? escapeBibtex(author.firstName) : '';
352
- if (!last && !first)
353
- return '';
354
- if (!first)
355
- return `{${last}}`;
356
618
  if (!last)
357
619
  return first;
358
- return `{${last}}, ${first}`;
620
+ if (author.suffix)
621
+ return `{${last}}, ${escapeBibtex(author.suffix)},${first && ` ${first}`}`;
622
+ return first ? `{${last}}, ${first}` : `{${last}}`;
359
623
  }
360
624
  // ---------------------------------------------------------------------------
361
625
  // APA 7th Edition
362
626
  // ---------------------------------------------------------------------------
363
627
  /**
364
628
  * Format an editor in APA's "In E. E. Editor (Ed.)," position: initials first,
365
- * surname last — the inverse of the author position.
629
+ * surname last — the inverse of the author position — and a suffix after the
630
+ * surname (`M. P. Adam Jr`).
366
631
  */
367
632
  function formatEditorApa(editor) {
368
633
  if (editor.collectiveName)
@@ -378,7 +643,7 @@ function formatEditorApa(editor) {
378
643
  .map((c) => `${c}.`)
379
644
  .join(' ')
380
645
  : '';
381
- return [initials, editor.lastName].filter(Boolean).join(' ');
646
+ return [initials, editor.lastName].filter(Boolean).join(' ') + trailingSuffix(editor);
382
647
  }
383
648
  /** APA editor list: comma-separated, `& ` before the last name. */
384
649
  function formatEditorsApa(editors) {
@@ -443,7 +708,7 @@ function formatApaBook(article) {
443
708
  const url = bookAccessUrl(article);
444
709
  if (url)
445
710
  parts.push(url);
446
- return parts.join(' ');
711
+ return collapseWhitespace(parts.join(' '));
447
712
  }
448
713
  /**
449
714
  * Format a PubMed article as an APA 7th edition citation.
@@ -456,7 +721,8 @@ function formatApaBook(article) {
456
721
  * ```
457
722
  * Title. (Year). *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
458
723
  * ```
459
- * A Bookshelf record routes to {@link formatApaBook}.
724
+ * A retracted article ends on `(Retraction published <RefSource>)`. A Bookshelf
725
+ * record routes to {@link formatApaBook}.
460
726
  */
461
727
  export function formatApa(article) {
462
728
  if (isBookRecord(article))
@@ -487,21 +753,21 @@ export function formatApa(article) {
487
753
  // Journal, volume, issue, pages
488
754
  const journal = article.journalInfo;
489
755
  if (journal?.title) {
490
- let journalPart = `*${journal.title}*`;
756
+ const name = citedJournalName(journal.title, journal.isoAbbreviation);
757
+ let journalPart = `*${titleCaseJournal(name, APA_MINOR_WORDS)}*`;
491
758
  if (journal.volume) {
492
759
  journalPart += `, *${journal.volume}*`;
493
760
  if (journal.issue) {
494
761
  journalPart += `(${journal.issue})`;
495
762
  }
496
763
  }
497
- if (journal.pages) {
498
- journalPart += `, ${journal.pages}`;
764
+ // APA 7 p. 294-295: an article number takes the page range's place
765
+ const number = articleNumber(journal);
766
+ if (number) {
767
+ journalPart += `, Article ${number}`;
499
768
  }
500
- else {
501
- // APA 7 p. 294-295: an article number takes the page range's place
502
- const locator = articleLocator(journal);
503
- if (locator)
504
- journalPart += `, Article ${locator}`;
769
+ else if (journal.pages) {
770
+ journalPart += `, ${expandPageRanges(journal.pages, '\u{2013}')}`;
505
771
  }
506
772
  journalPart += '.';
507
773
  parts.push(journalPart);
@@ -510,18 +776,23 @@ export function formatApa(article) {
510
776
  if (article.doi) {
511
777
  parts.push(`https://doi.org/${article.doi}`);
512
778
  }
513
- return parts.join(' ');
779
+ // APA 7 notes a retraction after the DOI. Its own form names the notice's
780
+ // year, full journal title, and location, which `RefSource` does not carry
781
+ // apart; NCBI's exporter fills the parenthetical with `RefSource` instead.
782
+ const retractions = citationNotices(article)
783
+ .filter((notice) => notice.refType === 'RetractionIn')
784
+ .map((notice) => stripTrailingPeriod(notice.refSource));
785
+ if (retractions.length > 0) {
786
+ parts.push(`(Retraction published ${retractions.join('; ')})`);
787
+ }
788
+ return collapseWhitespace(parts.join(' '));
514
789
  }
515
790
  // ---------------------------------------------------------------------------
516
791
  // MLA 9th Edition
517
792
  // ---------------------------------------------------------------------------
518
- /** MLA renders editors first-name-first after `edited by`. */
793
+ /** MLA renders editors first-name-first after `edited by`, as it does a non-first author. */
519
794
  function formatEditorsMla(editors) {
520
- const names = editors
521
- .map((editor) => editor.collectiveName
522
- ? editor.collectiveName
523
- : [editor.firstName, editor.lastName].filter(Boolean).join(' '))
524
- .filter(Boolean);
795
+ const names = editors.map((editor) => formatAuthorMla(editor, false)).filter(Boolean);
525
796
  if (names.length === 0)
526
797
  return '';
527
798
  if (names.length === 1)
@@ -565,7 +836,7 @@ function formatMlaBook(article) {
565
836
  detailParts.push(date);
566
837
  if (detailParts.length)
567
838
  parts.push(terminate(detailParts.join(', ')));
568
- return parts.join(' ');
839
+ return collapseWhitespace(parts.join(' '));
569
840
  }
570
841
  /**
571
842
  * Format a PubMed article as an MLA 9th edition citation.
@@ -595,7 +866,8 @@ export function formatMla(article) {
595
866
  const journal = article.journalInfo;
596
867
  if (journal?.title) {
597
868
  const detailParts = [];
598
- detailParts.push(`*${journal.title}*`);
869
+ const name = citedJournalName(journal.title, journal.isoAbbreviation);
870
+ detailParts.push(`*${titleCaseJournal(name, MLA_MINOR_WORDS)}*`);
599
871
  if (journal.volume) {
600
872
  detailParts.push(`vol. ${journal.volume}`);
601
873
  }
@@ -606,25 +878,24 @@ export function formatMla(article) {
606
878
  if (year !== 'n.d.') {
607
879
  detailParts.push(year);
608
880
  }
609
- if (journal.pages) {
881
+ // MLA 9 codifies no article-number form; citation guides converge on
882
+ // "art. <value>" in the page position.
883
+ const number = articleNumber(journal);
884
+ if (number) {
885
+ detailParts.push(`art. ${number}`);
886
+ }
887
+ else if (journal.pages) {
610
888
  // MLA 9 §6.56: "p." for a single page, "pp." for a range
611
889
  const isRange = /[-\u2013\u2014]/.test(journal.pages);
612
890
  detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
613
891
  }
614
- else {
615
- // MLA 9 codifies no article-number form; citation guides converge on
616
- // "art. <value>" in the page position.
617
- const locator = articleLocator(journal);
618
- if (locator)
619
- detailParts.push(`art. ${locator}`);
620
- }
621
892
  parts.push(`${detailParts.join(', ')}.`);
622
893
  }
623
894
  // DOI
624
895
  if (article.doi) {
625
896
  parts.push(`https://doi.org/${article.doi}.`);
626
897
  }
627
- return parts.join(' ');
898
+ return collapseWhitespace(parts.join(' '));
628
899
  }
629
900
  // ---------------------------------------------------------------------------
630
901
  // BibTeX
@@ -701,11 +972,12 @@ export function formatBibtex(article) {
701
972
  if (journal?.issue) {
702
973
  fields.push(['number', escapeBibtex(journal.issue)]);
703
974
  }
704
- // Pages — or, with no pagination, biblatex's `eid` for the article number.
705
- // Classic BibTeX has no article-number field and overloading `pages` is
706
- // imprecise; `eid` is broadly supported (acmart included).
975
+ // Pages, each range written in full (#228) — or, with no pagination,
976
+ // biblatex's `eid` for the article number. Classic BibTeX has no
977
+ // article-number field and overloading `pages` is imprecise; `eid` is broadly
978
+ // supported (acmart included).
707
979
  if (journal?.pages) {
708
- fields.push(['pages', escapeBibtex(journal.pages)]);
980
+ fields.push(['pages', escapeBibtex(expandPageRanges(journal.pages))]);
709
981
  }
710
982
  else if (!book) {
711
983
  const locator = articleLocator(journal);
@@ -738,29 +1010,38 @@ export function formatBibtex(article) {
738
1010
  if (url)
739
1011
  fields.push(['url', url]);
740
1012
  }
741
- // Keywords — merge article keywords with MeSH descriptor names
742
- const keywordSet = new Set();
743
- for (const k of article.keywords ?? [])
744
- keywordSet.add(k);
745
- for (const m of article.meshTerms ?? []) {
746
- if (m.descriptorName)
747
- keywordSet.add(m.descriptorName);
1013
+ // Keywords — article keywords merged with MeSH descriptor names. Brace-wrap
1014
+ // each term so MeSH descriptors that carry internal commas in their inverted
1015
+ // form (e.g. "Databases, Protein") stay a single keyword — biblatex's
1016
+ // comma-separated list parser reads a braced item as one element.
1017
+ const keywords = mergedKeywords(article);
1018
+ if (keywords.length > 0) {
1019
+ fields.push(['keywords', keywords.map((k) => `{${escapeBibtex(k)}}`).join(', ')]);
748
1020
  }
749
- // Brace-wrap each term so MeSH descriptors that carry internal commas in
750
- // their inverted form (e.g. "Databases, Protein") stay a single keyword —
751
- // biblatex's comma-separated list parser reads a braced item as one element.
752
- if (keywordSet.size > 0) {
753
- const keywords = [...keywordSet].map((k) => `{${escapeBibtex(k)}}`).join(', ');
754
- fields.push(['keywords', keywords]);
755
- }
756
- // Build entry
1021
+ // Build entry — each value on its own line, whitespace runs collapsed (#233)
757
1022
  const maxKeyLen = Math.max(...fields.map(([k]) => k.length));
758
- const fieldLines = fields.map(([k, v]) => ` ${k.padEnd(maxKeyLen)} = {${v}}`).join(',\n');
1023
+ const fieldLines = fields
1024
+ .map(([k, v]) => ` ${k.padEnd(maxKeyLen)} = {${collapseWhitespace(v)}}`)
1025
+ .join(',\n');
759
1026
  return `@${entryType}{${key},\n${fieldLines}\n}`;
760
1027
  }
761
1028
  // ---------------------------------------------------------------------------
762
1029
  // RIS
763
1030
  // ---------------------------------------------------------------------------
1031
+ /**
1032
+ * An `AU` or `A2` value: `Lastname, Firstname`, or `Lastname, Firstname, Suffix`.
1033
+ * Positional, so a suffixed name with no first name keeps the empty slot
1034
+ * (`Frenck, , Jr`) rather than reading the suffix as a first name.
1035
+ */
1036
+ function formatNameRis(person) {
1037
+ if (person.collectiveName)
1038
+ return person.collectiveName;
1039
+ const last = person.lastName ?? '';
1040
+ const first = person.firstName ?? '';
1041
+ if (last && person.suffix)
1042
+ return `${last}, ${first}, ${person.suffix}`;
1043
+ return first ? `${last}, ${first}` : last;
1044
+ }
764
1045
  /**
765
1046
  * Format a PubMed article as a RIS record.
766
1047
  *
@@ -769,9 +1050,12 @@ export function formatBibtex(article) {
769
1050
  */
770
1051
  export function formatRis(article) {
771
1052
  const lines = [];
1053
+ // Every value on its own tag line: a line break inside one would leave an
1054
+ // untagged line, or a blank one that ends the record for strict parsers (#233)
772
1055
  const tag = (code, value) => {
773
- if (value)
774
- lines.push(`${code} - ${value}`);
1056
+ const text = value && collapseWhitespace(value);
1057
+ if (text)
1058
+ lines.push(`${code} - ${text}`);
775
1059
  };
776
1060
  const book = isBookRecord(article) ? article.book : undefined;
777
1061
  // Type of reference — the record type for a book, else the publication types
@@ -782,20 +1066,8 @@ export function formatRis(article) {
782
1066
  : firstMappedType(article.publicationTypes, RIS_REFERENCE_TYPE, 'JOUR');
783
1067
  lines.push(`TY - ${refType}`);
784
1068
  // Authors — one AU tag per author
785
- if (article.authors?.length) {
786
- for (const author of article.authors) {
787
- if (author.collectiveName) {
788
- tag('AU', author.collectiveName);
789
- }
790
- else {
791
- const last = author.lastName ?? '';
792
- const first = author.firstName ?? '';
793
- if (last || first) {
794
- tag('AU', first ? `${last}, ${first}` : last);
795
- }
796
- }
797
- }
798
- }
1069
+ for (const author of article.authors ?? [])
1070
+ tag('AU', formatNameRis(author));
799
1071
  // Title
800
1072
  tag('TI', article.title);
801
1073
  // Container — the journal, or the book a chapter sits in. `BT` is the book
@@ -810,14 +1082,8 @@ export function formatRis(article) {
810
1082
  if (book) {
811
1083
  if (article.recordType === 'book-chapter')
812
1084
  tag('BT', book.title);
813
- for (const editor of book.editors ?? []) {
814
- const last = editor.lastName ?? '';
815
- const first = editor.firstName ?? '';
816
- if (editor.collectiveName)
817
- tag('A2', editor.collectiveName);
818
- else if (last || first)
819
- tag('A2', first ? `${last}, ${first}` : last);
820
- }
1085
+ for (const editor of book.editors ?? [])
1086
+ tag('A2', formatNameRis(editor));
821
1087
  tag('PB', book.publisher);
822
1088
  tag('CY', book.publisherLocation);
823
1089
  tag('ET', book.edition);
@@ -834,7 +1100,7 @@ export function formatRis(article) {
834
1100
  // Volume & Issue
835
1101
  tag('VL', journal?.volume);
836
1102
  tag('IS', journal?.issue);
837
- // Pages — split into start/end, expanding PubMed's truncated-end convention.
1103
+ // Pages — the first page and the last, written in full ({@link splitPages}).
838
1104
  // With no pagination, the article number goes on `C7` (the attested RIS
839
1105
  // convention for Article Number), never on SP/EP, which hold absolute pages.
840
1106
  if (journal?.pages) {
@@ -866,22 +1132,12 @@ export function formatRis(article) {
866
1132
  if (url)
867
1133
  lines.push(`UR - ${url}`);
868
1134
  }
869
- // Keywords — merge article keywords with MeSH descriptor names
870
- const keywordSet = new Set();
871
- for (const k of article.keywords ?? [])
872
- keywordSet.add(k);
873
- for (const m of article.meshTerms ?? []) {
874
- if (m.descriptorName)
875
- keywordSet.add(m.descriptorName);
876
- }
877
- for (const kw of keywordSet) {
1135
+ // Keywords — article keywords merged with MeSH descriptor names
1136
+ for (const kw of mergedKeywords(article)) {
878
1137
  tag('KW', kw);
879
1138
  }
880
- // Abstract — collapse internal whitespace so blank lines don't break strict
881
- // RIS parsers that terminate records at blank lines
882
- if (article.abstractText) {
883
- tag('AB', collapseWhitespace(article.abstractText));
884
- }
1139
+ // Abstract — a structured abstract's paragraphs flatten onto the one line
1140
+ tag('AB', article.abstractText);
885
1141
  // End of record (trailing space per RIS spec)
886
1142
  lines.push('ER - ');
887
1143
  return lines.join('\n');
@@ -891,8 +1147,8 @@ export function formatRis(article) {
891
1147
  // ---------------------------------------------------------------------------
892
1148
  /**
893
1149
  * Format a single author in Vancouver style: `Surname AB` — surname, a space,
894
- * then initials with no periods or internal spaces. Collective/group authors
895
- * return their name directly.
1150
+ * then initials with no periods or internal spaces, then any suffix after a
1151
+ * space (`Frenck RW Jr`). Collective/group authors return their name directly.
896
1152
  */
897
1153
  function formatAuthorVancouver(author) {
898
1154
  if (author.collectiveName)
@@ -913,9 +1169,7 @@ function formatAuthorVancouver(author) {
913
1169
  : '';
914
1170
  if (!last)
915
1171
  return initials;
916
- if (!initials)
917
- return last;
918
- return `${last} ${initials}`;
1172
+ return `${initials ? `${last} ${initials}` : last}${trailingSuffix(author)}`;
919
1173
  }
920
1174
  /**
921
1175
  * Format the full author list for Vancouver: list every author for six or
@@ -976,8 +1230,16 @@ function formatVancouverBook(article) {
976
1230
  const url = bookshelfUrl(book);
977
1231
  if (url)
978
1232
  segments.push(`Available from: ${url}`);
979
- return segments.join(' ');
1233
+ return collapseWhitespace(segments.join(' '));
980
1234
  }
1235
+ /**
1236
+ * The notices Vancouver notes after the reference, each with Citing Medicine's
1237
+ * label (Boxes 58–59). NCBI's own NLM export prints these two and no others.
1238
+ */
1239
+ const VANCOUVER_NOTE_LABELS = {
1240
+ RetractionIn: 'Retraction in',
1241
+ ErratumIn: 'Erratum in',
1242
+ };
981
1243
  /**
982
1244
  * Format a PubMed article as a Vancouver (ICMJE/NLM) reference — the numbered
983
1245
  * style used by NEJM, Lancet, JAMA, BMJ, and most biomedical journals.
@@ -989,8 +1251,9 @@ function formatVancouverBook(article) {
989
1251
  * ```
990
1252
  * Journal name uses the NLM/ISO abbreviation when available; pages are used as
991
1253
  * PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
992
- * period so it stays copy-pasteable. An article number replaces nothing — with
1254
+ * period so it stays copy-pasteable, unless a note follows it. An article number replaces nothing — with
993
1255
  * no pagination it trails the source as an NLM note (`. pii: 2400512.`). A
1256
+ * retraction or erratum follows as a note (`Retraction in: <RefSource>`). A
994
1257
  * Bookshelf record routes to {@link formatVancouverBook}.
995
1258
  */
996
1259
  export function formatVancouver(article) {
@@ -1035,11 +1298,20 @@ export function formatVancouver(article) {
1035
1298
  const label = journal?.elocationIdType;
1036
1299
  segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
1037
1300
  }
1038
- // DOI — NLM "doi: <doi>" form; no trailing period (would corrupt the DOI)
1301
+ // Citing Medicine's retraction and erratum notes (Boxes 58–59), one per
1302
+ // notice in NCBI's order with `RefSource` verbatim, as NCBI's NLM export
1303
+ // prints them. Each ends on exactly one period: some `RefSource`s carry none.
1304
+ const notes = citationNotices(article).flatMap((notice) => {
1305
+ const label = VANCOUVER_NOTE_LABELS[notice.refType];
1306
+ return label ? [terminate(`${label}: ${notice.refSource}`)] : [];
1307
+ });
1308
+ // DOI — NLM "doi: <doi>" form with no trailing period, which a reader copying
1309
+ // it would take as part of the DOI; a note after it closes it, as in NLM's export
1039
1310
  if (article.doi) {
1040
- segments.push(`doi: ${article.doi}`);
1311
+ segments.push(notes.length > 0 ? `doi: ${article.doi}.` : `doi: ${article.doi}`);
1041
1312
  }
1042
- return segments.join(' ');
1313
+ segments.push(...notes);
1314
+ return collapseWhitespace(segments.join(' '));
1043
1315
  }
1044
1316
  // ---------------------------------------------------------------------------
1045
1317
  // Dispatchers