@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
@@ -7,7 +7,7 @@
7
7
  * inline children and drops body sections for markup-heavy articles.
8
8
  * @module src/services/ncbi/parsing/pmc-article-parser
9
9
  */
10
- import { attrOf, childrenOf, findAll, findAllDescendants, findOne, IDENTIFIER_TAGS, isTextNode, lineTextContent, rawTextContent, selectAlternative, TextAssembler, tagNameOf, textContent, textContentExcluding, textOf, } from './pmc-xml-helpers.js';
10
+ import { attrOf, CLOSING_PUNCTUATION, childrenOf, findAll, findAllDescendants, findOne, IDENTIFIER_TAGS, isMathTag, isTextNode, linearMath, lineTextContent, rawTextContent, selectAlternative, TextAssembler, tagNameOf, textContent, textContentExcluding, textOf, } from './pmc-xml-helpers.js';
11
11
  /**
12
12
  * Block content extracted into a field of its own and therefore left out of the
13
13
  * prose a section contributes. Each one nested inside a `<p>` would otherwise be
@@ -125,7 +125,11 @@ function isAuthorContrib(contrib) {
125
125
  const contribType = attrOf(contrib, 'contrib-type');
126
126
  return !contribType || contribType === 'author';
127
127
  }
128
- /** Extract authors from a single `<contrib-group>` node. Non-author contributors are skipped. */
128
+ /**
129
+ * Extract authors from a single `<contrib-group>` node. Non-author contributors
130
+ * are skipped. A name part the deposit lacks is omitted; a `<suffix>` is kept
131
+ * verbatim (`IV`, never rewritten to `4th`). (#226)
132
+ */
129
133
  export function extractJatsAuthors(contribGroup) {
130
134
  if (!contribGroup)
131
135
  return [];
@@ -144,9 +148,11 @@ export function extractJatsAuthors(contribGroup) {
144
148
  if (nameNode) {
145
149
  const lastName = textContent(findOne(nameNode, 'surname')) || undefined;
146
150
  const givenNames = textContent(findOne(nameNode, 'given-names')) || undefined;
151
+ const suffix = textContent(findOne(nameNode, 'suffix')) || undefined;
147
152
  authors.push({
148
153
  ...(lastName && { lastName }),
149
154
  ...(givenNames && { givenNames }),
155
+ ...(suffix && { suffix }),
150
156
  });
151
157
  }
152
158
  }
@@ -327,6 +333,12 @@ function extractAbstract(articleMeta) {
327
333
  * so its `[Box]` and `[End of box]` lines stand alone. `ownLineFirst` reports
328
334
  * either on the first line, which the caller starts below the section's
329
335
  * heading (PMC10869376). (#202, #210)
336
+ *
337
+ * A run of {@link BULLET_STATEMENT} paragraphs — a "Key points" section a
338
+ * deposit writes as `<p>• …</p>` rather than as a `<list>` — lays out the same
339
+ * way: one paragraph per line with its bullet kept, set apart from the
340
+ * statements around it by a blank line, and the first below the heading
341
+ * (PMC10799778). (#218)
330
342
  */
331
343
  function abstractProse(nodes) {
332
344
  const prose = nodes.filter((node) => {
@@ -334,15 +346,26 @@ function abstractProse(nodes) {
334
346
  return tag !== 'title' && tag !== 'label';
335
347
  });
336
348
  const blocks = flowParts(prose, STATEMENT_BLOCK_TAGS);
349
+ const bullet = blocks.map((block) => !block.ownLineFirst && BULLET_STATEMENT.test(block.text));
337
350
  let text = '';
338
351
  for (const [i, block] of blocks.entries()) {
339
352
  const previous = blocks[i - 1];
340
- if (previous)
341
- text += previous.ownLineLast || block.ownLineFirst ? '\n\n' : ' ';
353
+ if (previous) {
354
+ const blankLine = previous.ownLineLast || block.ownLineFirst || bullet[i - 1] !== bullet[i];
355
+ text += blankLine ? '\n\n' : bullet[i] ? '\n' : ' ';
356
+ }
342
357
  text += block.text;
343
358
  }
344
- return { ownLineFirst: blocks[0]?.ownLineFirst ?? false, text };
359
+ return { ownLineFirst: (blocks[0]?.ownLineFirst ?? false) || (bullet[0] ?? false), text };
345
360
  }
361
+ /**
362
+ * A statement deposited as a bullet point: its text opens with a bullet
363
+ * character — `•` U+2022, `‣` U+2023, `⁃` U+2043 or `◦` U+25E6 — and carries
364
+ * text after it. Characters that open a paragraph with another meaning stay
365
+ * out: `·` and `∙` (multiplication), `*` and daggers (footnote marks), hyphens
366
+ * and dashes (minus signs, ranges), `■` (an end-of-proof mark). (#218)
367
+ */
368
+ const BULLET_STATEMENT = /^[•‣⁃◦]\s*\S/;
346
369
  function extractKeywords(articleMeta) {
347
370
  if (!articleMeta)
348
371
  return [];
@@ -386,18 +409,23 @@ function flushRun(flow) {
386
409
  * prose, {@link STATEMENT_BLOCK_TAGS} for a container whose `<title>` and `<p>`
387
410
  * children are separate statements rather than one continuous sentence.
388
411
  *
389
- * `<tex-math>`, `<alternatives>` and the {@link IDENTIFIER_TAGS} are the elements
390
- * whose text is not the concatenation of their subtree, so this walk defers to
391
- * the shared rule rather than reading their children one at a time: a
392
- * `<tex-math>` contributes its LaTeX document body via `rawTextContent`, an
412
+ * `<tex-math>`, MathML `<math>`, `<alternatives>` and the {@link IDENTIFIER_TAGS}
413
+ * are the elements whose text is not the concatenation of their subtree, so
414
+ * this walk defers to the shared rule rather than reading their children one at
415
+ * a time: a `<tex-math>` contributes its LaTeX document body via
416
+ * `rawTextContent`, a `<math>` its TeX-style linear form via `linearMath`
417
+ * wherever it sits — an inline formula or directly in a `<p>` — an
393
418
  * `<alternatives>` re-enters this walk with its single chosen child, which keeps
394
419
  * a `<disp-formula>` found there at block position, and an `<institution-id>`
395
- * in a funding statement contributes nothing. (#135, #208)
420
+ * in a funding statement contributes nothing. (#135, #207, #208)
396
421
  *
397
422
  * The run is a {@link TextAssembler}, so prose follows the separator rules every
398
423
  * other text field does: a `<break/>`, an inline `<fn>` or a `<label>` beside its
399
- * text reads as one space rather than fusing the words on either side, and a run
400
- * of `<xref>` markers with nothing between them reads `[1,2,3,4]`. (#185, #197)
424
+ * text reads as one space rather than fusing the words on either side, a run of
425
+ * `<xref>` markers with nothing between them reads `[1,2,3,4]`, and a line break
426
+ * between a formula — or any element — and the period after it is dropped:
427
+ * `L<0.`, not `L<0 .`. A `<tex-math>` or `<math>` is opened and closed on the
428
+ * run like any element, so that rule sees it. (#185, #197, #225)
401
429
  */
402
430
  function walkFlow(nodes, flow, blockTags) {
403
431
  for (const node of nodes) {
@@ -414,8 +442,10 @@ function walkFlow(nodes, flow, blockTags) {
414
442
  walkFlow([chosen], flow, blockTags);
415
443
  continue;
416
444
  }
417
- if (tag === 'tex-math') {
418
- flow.run.text(rawTextContent(node));
445
+ if (tag === 'tex-math' || isMathTag(tag)) {
446
+ flow.run.open(tag);
447
+ flow.run.text(tag === 'tex-math' ? rawTextContent(node) : linearMath(node));
448
+ flow.run.close(tag);
419
449
  continue;
420
450
  }
421
451
  if (blockTags.has(tag)) {
@@ -471,9 +501,9 @@ function renderCaption(caption) {
471
501
  * nothing, which is what keeps a `<sec>` that only wraps one from surviving as
472
502
  * an empty "References" heading. (#116, #130)
473
503
  *
474
- * A `<list>` or `<def-list>`, and a delimited `<boxed-text>`, stand on lines of
475
- * their own at both edges; a container read through the default arm takes the
476
- * edges of its first and last blocks. (#202, #210)
504
+ * A `<list>`, `<def-list>` or `<array>`, and a delimited `<boxed-text>`, stand on
505
+ * lines of their own at both edges; a container read through the default arm
506
+ * takes the edges of its first and last blocks. (#202, #206, #210)
477
507
  */
478
508
  function renderBlock(node) {
479
509
  switch (tagNameOf(node)) {
@@ -488,6 +518,8 @@ function renderBlock(node) {
488
518
  return flowBlock(renderList(node, 0), true);
489
519
  case 'def-list':
490
520
  return flowBlock(renderDefList(node, 0), true);
521
+ case 'array':
522
+ return flowBlock(renderArray(node), true);
491
523
  case 'disp-quote':
492
524
  return flowBlock(renderDispQuote(node));
493
525
  case 'boxed-text':
@@ -545,12 +577,28 @@ function alternativePointer(alternatives) {
545
577
  * left in the section text and the `label` reported in `assets[]` cannot
546
578
  * disagree — the tool layer removes the marker by rebuilding it from that label,
547
579
  * so a label resolved one way here and another way there strands the marker in
548
- * the prose under `includeAssets: false`. (#130)
580
+ * the prose under `includeAssets: false`. That is also why the delimiter is
581
+ * stripped here rather than at render (see {@link floatLabel}). (#130, #240)
549
582
  */
550
583
  function assetLabel(node) {
551
- return (textContent(findOne(node, 'label')) ||
552
- textContent(findOne(assetPointer(node), 'label')) ||
553
- undefined);
584
+ return floatLabel(findOne(node, 'label')) ?? floatLabel(findOne(assetPointer(node), 'label'));
585
+ }
586
+ /** Delimiters a deposit may print after a table or asset label. */
587
+ const LABEL_DELIMITERS = new Set(['.', '|', ':']);
588
+ /**
589
+ * A table or asset `<label>`'s text without the one delimiter some deposits
590
+ * print after it — `Table 1.`, `Figure 1 |`, `Figure 1:` read `Table 1`,
591
+ * `Figure 1`. Every surface that names the item puts its own punctuation after
592
+ * the label: a heading's ` — ` before the caption, a list's `, `, a notice's
593
+ * closing period, which read `Table 2..` with the delimiter kept. Only the last
594
+ * character goes, so an internal one stays (`Fig. 1.` reads `Fig. 1`). A label
595
+ * that is nothing but a delimiter reads as no label. Reference labels are not
596
+ * read here and keep their printed form (#209). (#240)
597
+ */
598
+ function floatLabel(label) {
599
+ const text = textContent(label);
600
+ const stripped = LABEL_DELIMITERS.has(text.at(-1) ?? '') ? text.slice(0, -1).trimEnd() : text;
601
+ return stripped || undefined;
554
602
  }
555
603
  /**
556
604
  * The marker a lifted asset leaves at the position it occupied. Prose refers to
@@ -682,6 +730,28 @@ function renderDefList(defList, depth, withTitle = true) {
682
730
  }
683
731
  return lines.join('\n');
684
732
  }
733
+ /**
734
+ * Render an `<array>` — tabular material with no caption of its own, such as a
735
+ * glossary's grid of abbreviations — one row per line, its cells joined the way
736
+ * {@link renderDefList} joins a term to its definition:
737
+ * `- RSNA — Radiological Society of North America`. Read as flow, every cell ran
738
+ * into the next (`RSNARadiological Society of North AmericaRT-PCR…`). (#206)
739
+ */
740
+ function renderArray(array) {
741
+ const lines = [];
742
+ for (const row of findAllDescendants(array, 'tr')) {
743
+ const cells = childrenOf(row)
744
+ .filter((cell) => {
745
+ const tag = tagNameOf(cell);
746
+ return tag === 'td' || tag === 'th';
747
+ })
748
+ .map((cell) => textContent(cell))
749
+ .filter(Boolean);
750
+ if (cells.length > 0)
751
+ lines.push(`- ${cells.join(' — ')}`);
752
+ }
753
+ return lines.join('\n');
754
+ }
685
755
  /** Render a `<disp-quote>`: every line quoted, its `<attrib>` as a trailing attribution line. */
686
756
  function renderDispQuote(quote) {
687
757
  const lines = [];
@@ -723,7 +793,7 @@ function renderBoxedText(boxedText, { withLabel = true, withTitle = true } = {})
723
793
  blocks.push(...flowBlocks(parts, STATEMENT_BLOCK_TAGS));
724
794
  }
725
795
  else if (withLabel || tag !== 'label')
726
- blocks.push(...flowBlocks([child]));
796
+ blocks.push(...sectionBlocks(child));
727
797
  }
728
798
  return blocks.join('\n\n');
729
799
  }
@@ -773,7 +843,7 @@ function flattenSec(sec) {
773
843
  if (tag === 'sec')
774
844
  nested.push(...flattenSec(child));
775
845
  else
776
- blocks.push(...flowBlocks([child]));
846
+ blocks.push(...sectionBlocks(child));
777
847
  }
778
848
  const head = [heading, blocks.join('\n\n')].filter(Boolean).join('\n');
779
849
  return [...(head ? [head] : []), ...nested];
@@ -793,7 +863,7 @@ function renderPreformat(preformat) {
793
863
  const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
794
864
  /**
795
865
  * Render a `<disp-formula>`: its label, then a `<tex-math>` where the deposit
796
- * carries one (directly or under `<alternatives>`) and the flattened content
866
+ * carries one (directly or under `<alternatives>`) and the content's text
797
867
  * otherwise — usually `<mml:math>`, which is 69 of the 78 formulae in a
798
868
  * 68-record draw against 3 for `<tex-math>`. A graphic-only deposit has no
799
869
  * fallback text and contributes nothing at all rather than a bare label on an
@@ -802,6 +872,9 @@ const DISP_FORMULA_NON_BODY = new Set(['label', 'graphic', 'media']);
802
872
  * A `<tex-math>` contributes only the expression between `\begin{document}` and
803
873
  * `\end{document}`; `textContent` applies that rule, so the LaTeX preamble
804
874
  * publishers wrap around every formula never reaches the rendered line. (#135)
875
+ * A MathML `<math>` reads in the TeX-style linear form `textContent` gives it
876
+ * (`(L & M \\ M^{T} & N)<0`), so a formula keeps its scripts and matrix cells
877
+ * whichever notation the deposit carries. (#207)
805
878
  */
806
879
  function renderDispFormula(formula) {
807
880
  const texMath = findOne(formula, 'tex-math') ?? findOne(findOne(formula, 'alternatives'), 'tex-math');
@@ -834,8 +907,24 @@ function renderDispFormula(formula) {
834
907
  * content and stay in untitled sections of their own. (#148)
835
908
  */
836
909
  export function extractBodySections(body) {
837
- if (!body)
838
- return [];
910
+ return body ? extractSections(childrenOf(body), false) : [];
911
+ }
912
+ /**
913
+ * Extract back-matter sections from a `<back>` node: every child but
914
+ * `<ref-list>` — which `references[]` carries — and `<back>`'s own label and
915
+ * title, in source order. Each back-matter element is read as a `<sec>` is (see
916
+ * {@link BACK_SECTION_TAGS}), taking the title its element names when it carries
917
+ * neither a title nor a label (see {@link BACK_MATTER_TITLES}). (#206)
918
+ */
919
+ function extractBackSections(back) {
920
+ return back ? extractSections(childrenOf(back), true) : [];
921
+ }
922
+ /**
923
+ * Read the children of `<body>` — or, with `back`, of `<back>` — into sections,
924
+ * in document order. {@link extractBodySections} states the rules; back matter
925
+ * adds its own section elements and skips `<back>`'s label and title.
926
+ */
927
+ function extractSections(children, back) {
839
928
  const sections = [];
840
929
  let pendingBlocks = [];
841
930
  const flushPending = () => {
@@ -844,25 +933,135 @@ export function extractBodySections(body) {
844
933
  pendingBlocks = [];
845
934
  }
846
935
  };
847
- for (const child of childrenOf(body)) {
848
- if (tagNameOf(child) === 'sec') {
936
+ for (const child of children) {
937
+ const nested = nestedSections(child, back);
938
+ if (nested) {
849
939
  flushPending();
850
- const section = extractSection(child);
851
- if (section)
852
- sections.push(section);
940
+ sections.push(...nested);
853
941
  continue;
854
942
  }
943
+ if (back && SECTION_META_TAGS.has(tagNameOf(child) ?? ''))
944
+ continue;
855
945
  const title = ownBlockTitle(child);
856
946
  if (title) {
857
947
  flushPending();
858
948
  sections.push({ title, text: renderBlockWithoutTitle(child) });
859
949
  continue;
860
950
  }
861
- pendingBlocks.push(...flowBlocks([child]));
951
+ pendingBlocks.push(...sectionBlocks(child));
862
952
  }
863
953
  flushPending();
864
954
  return sections;
865
955
  }
956
+ // ─── Back Matter ────────────────────────────────────────────────────────────
957
+ /**
958
+ * Back-matter elements read as sections, each mapped to the title it goes by
959
+ * when it carries neither a `<title>` nor a `<label>`. Most `<fn-group>`s carry
960
+ * neither, and without a title a section renders as `untitled section` where no
961
+ * `sections` filter term can reach it. The title names the element, not a fact
962
+ * about the article; Europe PMC titles the same `<fn-group>` `Footnotes` when it
963
+ * moves back matter into `<body>`. A labelled element takes no fallback, so an
964
+ * `Appendix A` label never reads `Appendix Appendix A`. (#206)
965
+ */
966
+ const BACK_MATTER_TITLES = new Map([
967
+ ['ack', 'Acknowledgments'],
968
+ ['app', 'Appendix'],
969
+ ['bio', 'Biography'],
970
+ ['fn-group', 'Footnotes'],
971
+ ['glossary', 'Glossary'],
972
+ ['notes', 'Notes'],
973
+ ]);
974
+ /**
975
+ * Elements that form a section of their own in back matter: `<sec>`,
976
+ * `<app-group>`, and each element {@link BACK_MATTER_TITLES} names. One nested in
977
+ * another is a subsection, as a `<sec>` in a `<sec>` is, so a Springer
978
+ * `Declarations` `<notes>` keeps its inner notes' titles out of its text. In
979
+ * `<body>` only a `<sec>` forms one: a `<notes>` or `<fn-group>` closing a body
980
+ * `<sec>` stays that section's text, as it always read — a `<fn-group>` one
981
+ * paragraph per footnote, as in back matter (see {@link sectionBlocks}). (#206,
982
+ * #229)
983
+ */
984
+ const BACK_SECTION_TAGS = new Set([
985
+ 'sec',
986
+ 'app-group',
987
+ ...BACK_MATTER_TITLES.keys(),
988
+ ]);
989
+ /**
990
+ * True for an `<app-group>` with neither a title nor a label. It has no heading
991
+ * to give a section, so its children are read as if they sat in `<back>`: its
992
+ * `<app>`s stand as sections in its place, and the paragraphs, lists or figures
993
+ * JATS lets it carry ahead of them form an untitled section rather than being
994
+ * dropped. (#206)
995
+ */
996
+ function isHeadlessAppGroup(node) {
997
+ return (tagNameOf(node) === 'app-group' &&
998
+ !textContent(findOne(node, 'title')) &&
999
+ !textContent(findOne(node, 'label')));
1000
+ }
1001
+ /**
1002
+ * The sections `node` forms where it sits among a section's children, or among
1003
+ * `<body>`'s or `<back>`'s: a `<sec>` anywhere, and in back matter each element
1004
+ * of {@link BACK_SECTION_TAGS} — a headless `<app-group>` by way of its
1005
+ * children (see {@link isHeadlessAppGroup}). Undefined for anything that is
1006
+ * section content instead. (#206)
1007
+ */
1008
+ function nestedSections(node, back) {
1009
+ const tag = tagNameOf(node) ?? '';
1010
+ if (tag !== 'sec' && !(back && BACK_SECTION_TAGS.has(tag)))
1011
+ return;
1012
+ if (isHeadlessAppGroup(node))
1013
+ return extractSections(childrenOf(node), back);
1014
+ const section = extractSection(node, back);
1015
+ return section ? [section] : [];
1016
+ }
1017
+ /**
1018
+ * The title a back-matter element with neither a `<title>` nor a `<label>` goes
1019
+ * by (see {@link BACK_MATTER_TITLES}); undefined for every other element, a
1020
+ * `<sec>` included. (#206)
1021
+ */
1022
+ function fallbackTitle(node) {
1023
+ if (textContent(findOne(node, 'label')))
1024
+ return;
1025
+ return BACK_MATTER_TITLES.get(tagNameOf(node) ?? '');
1026
+ }
1027
+ /**
1028
+ * The paragraphs of a `<fn>` that stands as a block — in back matter, or in a
1029
+ * body `<sec>`, `<boxed-text>` or `<fn-group>` (see {@link sectionBlocks}): each
1030
+ * `<p>`, and each block one holds, on its own, with the footnote's `<label>`
1031
+ * leading the first. Read as one run, a footnote whose heading sits in a `<p>`
1032
+ * of its own fused that heading into the statement after it (`Competing
1033
+ * interests The authors declare…`), the fusion a titled `<notes>` is kept from.
1034
+ * (#206, #229)
1035
+ */
1036
+ function footnoteParagraphs(fn) {
1037
+ const label = textContent(findOne(fn, 'label'));
1038
+ const [first, ...rest] = flowBlocks(childrenOf(fn).filter((child) => tagNameOf(child) !== 'label'), STATEMENT_BLOCK_TAGS);
1039
+ if (first === undefined)
1040
+ return label ? [label] : [];
1041
+ return [label ? `${label} ${first}` : first, ...rest];
1042
+ }
1043
+ /**
1044
+ * The text blocks one child of a section contributes — of a `<sec>`, of
1045
+ * `<body>`, of a `<boxed-text>` or of a `<sec>` one flattens. A `<fn>` reads as
1046
+ * {@link footnoteParagraphs} reads it, and so does each `<fn>` of a
1047
+ * `<fn-group>`, the group's own `<title>` a paragraph ahead of them; anything
1048
+ * else is flow text. Europe PMC moves back matter into `<body>` as
1049
+ * `<sec sec-type="fn-group"><fn-group>…`, and read as flow text there a group's
1050
+ * footnotes ran into one paragraph where the PMC tier, reading the same group
1051
+ * from `<back>`, returned one per statement. A `<fn>` inside a `<p>` is no child
1052
+ * here and still reads inside its sentence. (#229)
1053
+ */
1054
+ function sectionBlocks(child) {
1055
+ switch (tagNameOf(child)) {
1056
+ case 'fn':
1057
+ return footnoteParagraphs(child);
1058
+ case 'fn-group':
1059
+ return childrenOf(child).flatMap(sectionBlocks);
1060
+ default:
1061
+ return flowBlocks([child]);
1062
+ }
1063
+ }
1064
+ // ─── Section Reading ────────────────────────────────────────────────────────
866
1065
  /**
867
1066
  * The title a block carries of its own, where the JATS content model puts one:
868
1067
  * a direct `<title>` on a `<def-list>` or `<list>`, the `<caption>` title on a
@@ -902,13 +1101,14 @@ function loneTitledBlock(sec) {
902
1101
  return title ? { node, title } : undefined;
903
1102
  }
904
1103
  /**
905
- * The title a `<sec>` goes by in the output: its own `<title>`, else the one
906
- * {@link loneTitledBlock} lifts. {@link extractSection} titles the section and
907
- * {@link collectSectioned} names the tables and assets inside it from this one
908
- * rule, so a `sections` filter keeps them together. (#169)
1104
+ * The title a `<sec>` — or a back-matter element read as one — goes by in the
1105
+ * output: its own `<title>`, else the one {@link loneTitledBlock} lifts, else
1106
+ * the one {@link fallbackTitle} names. {@link extractSection} titles the section
1107
+ * and {@link collectSectioned} names the tables and assets inside it from this
1108
+ * one rule, so a `sections` filter keeps them together. (#169, #206)
909
1109
  */
910
1110
  function secTitle(sec) {
911
- return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title;
1111
+ return textContent(findOne(sec, 'title')) || loneTitledBlock(sec)?.title || fallbackTitle(sec);
912
1112
  }
913
1113
  /** A block whose {@link ownBlockTitle} became its section's title, rendered without it. */
914
1114
  function renderBlockWithoutTitle(node) {
@@ -932,8 +1132,16 @@ function renderBlockWithoutTitle(node) {
932
1132
  *
933
1133
  * An untitled `<sec>` whose only content is a titled block takes that block's
934
1134
  * title, and the block renders without it (see {@link loneTitledBlock}). (#169)
1135
+ *
1136
+ * With `back`, `sec` may be any back-matter element {@link nestedSections}
1137
+ * reads as a section: the back-matter elements among its children are
1138
+ * subsections too, and with neither a title nor a label it takes the title its
1139
+ * element names. Each child contributes its own block, so every `<fn>` of a
1140
+ * `<fn-group>` is a paragraph of its own, and each `<p>` inside one too — in
1141
+ * back matter and, for a `<fn-group>` that stays a body section's text, in
1142
+ * `<body>` alike (see {@link sectionBlocks}). (#206, #229)
935
1143
  */
936
- function extractSection(sec) {
1144
+ function extractSection(sec, back = false) {
937
1145
  const lone = loneTitledBlock(sec);
938
1146
  if (lone) {
939
1147
  const label = textContent(findOne(sec, 'label')) || undefined;
@@ -953,14 +1161,14 @@ function extractSection(sec) {
953
1161
  label ??= textContent(child) || undefined;
954
1162
  continue;
955
1163
  }
956
- if (tag === 'sec') {
957
- const subsection = extractSection(child);
958
- if (subsection)
959
- subsections.push(subsection);
1164
+ const nested = nestedSections(child, back);
1165
+ if (nested) {
1166
+ subsections.push(...nested);
960
1167
  continue;
961
1168
  }
962
- textParts.push(...flowBlocks([child]));
1169
+ textParts.push(...sectionBlocks(child));
963
1170
  }
1171
+ title ??= fallbackTitle(sec);
964
1172
  const text = textParts.join('\n\n');
965
1173
  // A section left empty *because* its block content was lifted into `tables[]`
966
1174
  // survives as a heading-only entry: the heading is what places the table for a
@@ -983,10 +1191,10 @@ function extractSection(sec) {
983
1191
  * The walk covers the whole article rather than `<body>` alone: about a quarter
984
1192
  * of real tables sit in `<floats-group>`, `<back>/<sec>`, `<table-wrap-group>`
985
1193
  * or `<app-group>/<app>`, so a body-scoped extractor drops them. Each table
986
- * names the innermost enclosing `<sec>` wherever that sits — a back-matter or
987
- * appendix section counts, and naming it is the reader's only positional cue
988
- * there. Only a table inside no `<sec>` at all, such as a `<floats-group>`
989
- * deposit, carries no section name.
1194
+ * names the innermost enclosing section wherever that sits — a back-matter
1195
+ * `<sec>`, `<app>`, `<notes>` or `<fn-group>` counts, and naming it is the
1196
+ * reader's only positional cue there. Only a table inside no section at all,
1197
+ * such as a `<floats-group>` deposit, carries no section name. (#206)
990
1198
  *
991
1199
  * Only XHTML `<tr>`/`<td>`/`<th>` bodies are read. **Decision, not an
992
1200
  * oversight:** across a 283-table survey of open-access records, 276 were XHTML,
@@ -1009,19 +1217,21 @@ export function extractPmcTables(root) {
1009
1217
  * positional cue, and an element inside no section at all gets none. A
1010
1218
  * recognized element is not descended into — it parses its own subtree.
1011
1219
  *
1012
- * A section here is whatever {@link extractBodySections} reports as one, under
1013
- * the title it reports: a `<sec>` by {@link secTitle}, and a titled block
1014
- * sitting directly in `<body>` by its own title — so a figure in a titled
1015
- * body-level box names the box, and a `sections` filter on the box keeps it.
1016
- * A `<sec>` inside a `<boxed-text>` is no section: the box flattens it into its
1017
- * text (see {@link renderBoxedText}), so what sits in it names the section the
1018
- * box belongs to. (#169)
1220
+ * A section here is whatever {@link extractBodySections} and
1221
+ * {@link extractBackSections} report as one, under the title they report: a
1222
+ * `<sec>` — and inside `<back>`, each back-matter element read as one — by
1223
+ * {@link secTitle}, and a titled block sitting directly in `<body>` or `<back>`
1224
+ * by its own title — so a figure in a titled body-level box names the box, and
1225
+ * a `sections` filter on the box keeps it. A `<sec>` inside a `<boxed-text>` is
1226
+ * no section: the box flattens it into its text (see {@link renderBoxedText}),
1227
+ * so what sits in it names the section the box belongs to. (#169, #206)
1019
1228
  *
1020
1229
  * Shared by the table and asset walks so the section-title rule has one
1021
1230
  * definition and cannot drift between them.
1022
1231
  */
1023
- function collectSectioned(node, sectionTitle, out, take, inBox = false) {
1024
- const inBody = tagNameOf(node) === 'body';
1232
+ function collectSectioned(node, sectionTitle, out, take, inBox = false, inBack = false) {
1233
+ const parentTag = tagNameOf(node);
1234
+ const atTopLevel = parentTag === 'body' || parentTag === 'back';
1025
1235
  for (const child of childrenOf(node)) {
1026
1236
  const tag = tagNameOf(child);
1027
1237
  if (!tag)
@@ -1033,17 +1243,17 @@ function collectSectioned(node, sectionTitle, out, take, inBox = false) {
1033
1243
  }
1034
1244
  const own = inBox
1035
1245
  ? undefined
1036
- : tag === 'sec'
1246
+ : tag === 'sec' || (inBack && BACK_SECTION_TAGS.has(tag))
1037
1247
  ? secTitle(child)
1038
- : inBody
1248
+ : atTopLevel
1039
1249
  ? ownBlockTitle(child)
1040
1250
  : undefined;
1041
- collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text');
1251
+ collectSectioned(child, own || sectionTitle, out, take, inBox || tag === 'boxed-text', inBack || tag === 'back');
1042
1252
  }
1043
1253
  }
1044
1254
  function parseTableWrap(tableWrap, sectionTitle) {
1045
1255
  const id = attrOf(tableWrap, 'id');
1046
- const label = textContent(findOne(tableWrap, 'label')) || undefined;
1256
+ const label = floatLabel(findOne(tableWrap, 'label'));
1047
1257
  const caption = renderCaption(findOne(tableWrap, 'caption'));
1048
1258
  // One line per footnote or paragraph: a footer is a list of statements, and a
1049
1259
  // marker run into the previous note (`…dose.*No patient…`) misattributes it. (#185)
@@ -1184,9 +1394,10 @@ function extractTableRows(table) {
1184
1394
  * supplementary material sit outside `<body>` entirely — `<floats-group>`
1185
1395
  * deposits, `<back>/<sec>` and `<app-group>/<app>` placements, and figures
1186
1396
  * hanging off the abstract in `<front>`. Each asset names the innermost
1187
- * enclosing `<sec>` wherever that sits, an untitled one inheriting its parent's
1188
- * title, so the reader keeps a positional cue; an asset inside no `<sec>` at all
1189
- * carries no section name. (#130)
1397
+ * enclosing section wherever that sits — a back-matter element read as one
1398
+ * included — an untitled `<sec>` inheriting its parent's title, so the reader
1399
+ * keeps a positional cue; an asset inside no section at all carries no section
1400
+ * name. (#130, #206)
1190
1401
  */
1191
1402
  export function extractPmcAssets(root) {
1192
1403
  if (!root)
@@ -1237,13 +1448,20 @@ const PUB_ID_LABELS = {
1237
1448
  doi: 'DOI',
1238
1449
  pmcid: 'PMCID',
1239
1450
  };
1240
- /** Render one `<person-group>` child — a `<name>`, `<collab>`, or `<etal>`. */
1451
+ /**
1452
+ * Render one `<person-group>` child — a `<name>`, `<collab>`, or `<etal>`. A
1453
+ * name reads `Surname Given`, then any `<suffix>` verbatim (`Miller ER III`).
1454
+ */
1241
1455
  function renderCitationName(node) {
1242
1456
  const tag = tagNameOf(node);
1243
1457
  if (tag === 'name' || tag === 'string-name') {
1244
1458
  const surname = textContent(findOne(node, 'surname'));
1245
1459
  const given = textContent(findOne(node, 'given-names'));
1246
- return [surname, given].filter(Boolean).join(' ') || textContent(node);
1460
+ const name = [surname, given].filter(Boolean).join(' ');
1461
+ if (!name)
1462
+ return textContent(node);
1463
+ const suffix = textContent(findOne(node, 'suffix'));
1464
+ return suffix ? `${name} ${suffix}` : name;
1247
1465
  }
1248
1466
  if (tag === 'collab')
1249
1467
  return textContent(node);
@@ -1315,10 +1533,18 @@ const PERSON_TAGS = new Set(['name', 'string-name', 'collab', 'etal']);
1315
1533
  const SUPPLIED_ETAL = 'et al.';
1316
1534
  /** Any character other than whitespace. */
1317
1535
  const VISIBLE_TEXT = /\S/;
1536
+ /**
1537
+ * How much of the citation written so far a `<pub-id>` reads for a literal
1538
+ * prefix naming its type: the prefix, its `:` or `.`, and the whitespace around
1539
+ * them. A bounded window keeps each read constant-time, since every `<pub-id>`
1540
+ * in a citation asks again and reading the whole text would make the walk
1541
+ * quadratic. (#209)
1542
+ */
1543
+ const ID_PREFIX_WINDOW = 256;
1318
1544
  /**
1319
1545
  * Render one child element of a `<mixed-citation>` or name wrapper. `before` is
1320
- * the citation text written so far, which a `<pub-id>` reads for a literal
1321
- * prefix already naming its type. An `<etal>` reads as its own text, or as
1546
+ * the end of the citation text written so far ({@link ID_PREFIX_WINDOW}), which
1547
+ * a `<pub-id>` reads for a literal prefix already naming its type. An `<etal>` reads as its own text, or as
1322
1548
  * `et al.` when it is empty — as nearly every deposit leaves it.
1323
1549
  */
1324
1550
  function renderCitationPart(child, tag, before) {
@@ -1354,6 +1580,12 @@ function renderCitationPart(child, tag, before) {
1354
1580
  * - A label this renderer inserts is separated from whatever precedes it. (#115)
1355
1581
  * - A source `.` straight after a supplied `et al.` closes it rather than
1356
1582
  * doubling its period (`<etal/></person-group>. <article-title>`). (#209)
1583
+ * - The whitespace between an element and the {@link CLOSING_PUNCTUATION}
1584
+ * after it is dropped when any of it lies inside the element or it holds a
1585
+ * line break: the indentation a pretty-printed deposit leaves before
1586
+ * `</person-group>` or `</string-name>`, or the line break after
1587
+ * `</collab>`, reads `Henry L.` rather than `Henry L .`. Spaces the
1588
+ * citation's own text puts there (`</source> : <volume>`) are kept. (#219)
1357
1589
  *
1358
1590
  * Gaps the source already fills — `, ` text between names, a newline between a
1359
1591
  * `<string-name>`'s parts — are kept as written, so a fixed separator never
@@ -1370,9 +1602,19 @@ function renderCitationRun(node, wrapper) {
1370
1602
  let text = '';
1371
1603
  /** Whitespace not yet written: the end of the last element's text, then source whitespace after it. */
1372
1604
  let gap = '';
1605
+ /** `gap` begins inside the element written last. */
1606
+ let gapInside = false;
1373
1607
  /** The element written last, while no visible source text has followed it. */
1374
1608
  let previous;
1375
1609
  let suppliedEtal = false;
1610
+ /** At least the last {@link ID_PREFIX_WINDOW} characters of `text`, at most twice that. */
1611
+ let recent = '';
1612
+ const write = (chunk) => {
1613
+ text += chunk;
1614
+ recent += chunk;
1615
+ if (recent.length > 2 * ID_PREFIX_WINDOW)
1616
+ recent = recent.slice(-ID_PREFIX_WINDOW);
1617
+ };
1376
1618
  for (const child of childrenOf(node)) {
1377
1619
  if (isTextNode(child)) {
1378
1620
  let raw = textOf(child);
@@ -1382,23 +1624,32 @@ function renderCitationRun(node, wrapper) {
1382
1624
  }
1383
1625
  if (suppliedEtal)
1384
1626
  raw = raw.replace(/^(\s*)\./, '$1');
1385
- text += gap + raw;
1627
+ if (previous !== undefined) {
1628
+ const lead = raw.search(VISIBLE_TEXT);
1629
+ if (CLOSING_PUNCTUATION.has(raw.charAt(lead)) &&
1630
+ (gapInside || gap.includes('\n') || raw.slice(0, lead).includes('\n'))) {
1631
+ gap = '';
1632
+ raw = raw.slice(lead);
1633
+ }
1634
+ }
1635
+ write(gap + raw);
1386
1636
  gap = '';
1387
1637
  previous = undefined;
1388
1638
  suppliedEtal = false;
1389
1639
  continue;
1390
1640
  }
1391
1641
  const tag = tagNameOf(child) ?? '';
1392
- const part = renderCitationPart(child, tag, text);
1642
+ const part = renderCitationPart(child, tag, recent);
1393
1643
  if (!part.text && (wrapper || tag === 'pub-id'))
1394
1644
  continue;
1395
1645
  if (previous && PERSON_TAGS.has(previous) && PERSON_TAGS.has(tag))
1396
- text += ', ';
1646
+ write(', ');
1397
1647
  else
1398
- text += (previous && !gap) || part.labeled ? `${gap} ` : gap;
1648
+ write((previous && !gap) || part.labeled ? `${gap} ` : gap);
1399
1649
  const body = part.text.trimEnd();
1400
- text += body;
1650
+ write(body);
1401
1651
  gap = part.text.slice(body.length);
1652
+ gapInside = gap !== '';
1402
1653
  previous = tag;
1403
1654
  suppliedEtal = part.suppliedEtal;
1404
1655
  }
@@ -1517,6 +1768,7 @@ export function parsePmcArticle(articleNode) {
1517
1768
  const articleMeta = findOne(front, 'article-meta');
1518
1769
  const journalMeta = findOne(front, 'journal-meta');
1519
1770
  const body = findOne(articleNode, 'body');
1771
+ const back = findOne(articleNode, 'back');
1520
1772
  const pmcId = extractArticleId(articleMeta, 'pmcid') ?? extractArticleId(articleMeta, 'pmc-uid') ?? '';
1521
1773
  const pmid = extractArticleId(articleMeta, 'pmid');
1522
1774
  const doi = extractArticleId(articleMeta, 'doi');
@@ -1529,6 +1781,7 @@ export function parsePmcArticle(articleNode) {
1529
1781
  const abstract = extractAbstract(articleMeta);
1530
1782
  const keywords = extractKeywords(articleMeta);
1531
1783
  const sections = extractBodySections(body);
1784
+ const backSections = extractBackSections(back);
1532
1785
  const references = extractReferences(articleNode);
1533
1786
  const tables = extractPmcTables(articleNode);
1534
1787
  const assets = extractPmcAssets(articleNode);
@@ -1546,6 +1799,7 @@ export function parsePmcArticle(articleNode) {
1546
1799
  ...(abstract && { abstract }),
1547
1800
  ...(keywords.length > 0 && { keywords }),
1548
1801
  sections,
1802
+ ...(backSections.length > 0 && { backSections }),
1549
1803
  ...(references.length > 0 && { references }),
1550
1804
  ...(tables.length > 0 && { tables }),
1551
1805
  ...(assets.length > 0 && { assets }),