@cyanheads/pubmed-mcp-server 2.10.18 → 2.10.20

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 (121) hide show
  1. package/AGENTS.md +16 -15
  2. package/CLAUDE.md +16 -15
  3. package/Dockerfile +86 -36
  4. package/README.md +86 -109
  5. package/changelog/2.10.x/2.10.19.md +42 -0
  6. package/changelog/2.10.x/2.10.20.md +63 -0
  7. package/dist/mcp-server/resources/definitions/database-info.resource.d.ts +43 -1
  8. package/dist/mcp-server/resources/definitions/database-info.resource.d.ts.map +1 -1
  9. package/dist/mcp-server/resources/definitions/database-info.resource.js +2 -0
  10. package/dist/mcp-server/resources/definitions/database-info.resource.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/_text.js +10 -5
  13. package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/_visible-text.d.ts +19 -0
  15. package/dist/mcp-server/tools/definitions/_visible-text.d.ts.map +1 -0
  16. package/dist/mcp-server/tools/definitions/_visible-text.js +22 -0
  17. package/dist/mcp-server/tools/definitions/_visible-text.js.map +1 -0
  18. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +11 -4
  19. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/convert-ids.tool.js +1 -1
  21. package/dist/mcp-server/tools/definitions/convert-ids.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +20 -7
  23. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +68 -15
  25. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +37 -12
  27. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +590 -481
  29. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +5 -5
  31. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/find-related.tool.js +31 -13
  33. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +11 -4
  35. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +26 -21
  37. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +109 -55
  39. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +15 -7
  41. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +19 -11
  43. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +20 -2
  45. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +98 -14
  47. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  48. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +21 -6
  49. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +42 -10
  51. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  52. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +42 -18
  53. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  54. package/dist/mcp-server/tools/definitions/search-articles.tool.js +220 -48
  55. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  56. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +15 -7
  57. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  58. package/dist/mcp-server/tools/definitions/spell-check.tool.js +13 -9
  59. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  60. package/dist/services/error-contracts.d.ts +43 -72
  61. package/dist/services/error-contracts.d.ts.map +1 -1
  62. package/dist/services/error-contracts.js +44 -76
  63. package/dist/services/error-contracts.js.map +1 -1
  64. package/dist/services/europe-pmc/api-client.d.ts +8 -2
  65. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  66. package/dist/services/europe-pmc/api-client.js +13 -8
  67. package/dist/services/europe-pmc/api-client.js.map +1 -1
  68. package/dist/services/europe-pmc/europe-pmc-service.d.ts +25 -2
  69. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  70. package/dist/services/europe-pmc/europe-pmc-service.js +90 -25
  71. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  72. package/dist/services/europe-pmc/types.d.ts +6 -1
  73. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  74. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  75. package/dist/services/ncbi/api-client.js +2 -3
  76. package/dist/services/ncbi/api-client.js.map +1 -1
  77. package/dist/services/ncbi/formatting/citation-formatter.d.ts +24 -0
  78. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  79. package/dist/services/ncbi/formatting/citation-formatter.js +147 -46
  80. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  81. package/dist/services/ncbi/ncbi-service.d.ts +18 -2
  82. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  83. package/dist/services/ncbi/ncbi-service.js +182 -90
  84. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  85. package/dist/services/ncbi/parsing/article-parser.d.ts +15 -1
  86. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  87. package/dist/services/ncbi/parsing/article-parser.js +67 -29
  88. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  89. package/dist/services/ncbi/parsing/esummary-parser.d.ts +9 -0
  90. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  91. package/dist/services/ncbi/parsing/esummary-parser.js +26 -2
  92. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  93. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  94. package/dist/services/ncbi/parsing/pmc-article-parser.js +395 -147
  95. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  96. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +73 -5
  97. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  98. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +223 -27
  99. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  100. package/dist/services/ncbi/parsing/text-helpers.d.ts +8 -6
  101. package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
  102. package/dist/services/ncbi/parsing/text-helpers.js +95 -19
  103. package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
  104. package/dist/services/ncbi/response-handler.d.ts +20 -12
  105. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  106. package/dist/services/ncbi/response-handler.js +134 -38
  107. package/dist/services/ncbi/response-handler.js.map +1 -1
  108. package/dist/services/ncbi/types.d.ts +35 -2
  109. package/dist/services/ncbi/types.d.ts.map +1 -1
  110. package/dist/services/openalex/api-client.d.ts.map +1 -1
  111. package/dist/services/openalex/api-client.js +6 -7
  112. package/dist/services/openalex/api-client.js.map +1 -1
  113. package/dist/services/openalex/openalex-service.d.ts +10 -6
  114. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  115. package/dist/services/openalex/openalex-service.js +14 -12
  116. package/dist/services/openalex/openalex-service.js.map +1 -1
  117. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  118. package/dist/services/unpaywall/unpaywall-service.js +1 -3
  119. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  120. package/package.json +16 -13
  121. package/server.json +10 -24
@@ -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, isTextNode, rawTextContent, selectAlternative, tagNameOf, textContent, textContentExcluding, textOf, } from './pmc-xml-helpers.js';
10
+ import { attrOf, childrenOf, findAll, findAllDescendants, findOne, IDENTIFIER_TAGS, isTextNode, 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
@@ -116,14 +116,22 @@ function extractArticleId(articleMeta, pubIdType) {
116
116
  return;
117
117
  }
118
118
  // ─── Authors & Affiliations ─────────────────────────────────────────────────
119
+ /**
120
+ * True for a `<contrib>` that is an author: `contrib-type="author"` or no type
121
+ * at all. Editors, reviewers and every other typed contributor are not, and
122
+ * neither they nor their affiliations reach the output.
123
+ */
124
+ function isAuthorContrib(contrib) {
125
+ const contribType = attrOf(contrib, 'contrib-type');
126
+ return !contribType || contribType === 'author';
127
+ }
119
128
  /** Extract authors from a single `<contrib-group>` node. Non-author contributors are skipped. */
120
129
  export function extractJatsAuthors(contribGroup) {
121
130
  if (!contribGroup)
122
131
  return [];
123
132
  const authors = [];
124
133
  for (const contrib of findAll(contribGroup, 'contrib')) {
125
- const contribType = attrOf(contrib, 'contrib-type');
126
- if (contribType && contribType !== 'author')
134
+ if (!isAuthorContrib(contrib))
127
135
  continue;
128
136
  const collab = findOne(contrib, 'collab');
129
137
  if (collab) {
@@ -144,16 +152,42 @@ export function extractJatsAuthors(contribGroup) {
144
152
  }
145
153
  return authors;
146
154
  }
155
+ /**
156
+ * Every `<aff>` in the front matter, in document order: directly under
157
+ * `<article-meta>`, inside a `<contrib-group>`, or inside one `<contrib>` of it.
158
+ * Most deposits put them under `<contrib-group>` — 131 articles of a
159
+ * 187-article draw carried theirs only there — and reading `<article-meta>`
160
+ * alone returned none for those. Some deposits repeat one affiliation as a separate
161
+ * `<aff>` in every `<contrib>` it applies to; the text is reported once. (#196)
162
+ *
163
+ * An affiliation belongs to the authors only: an `<aff>` inside a non-author
164
+ * `<contrib>` (PMC10666927's reviewing editors), and one directly under a
165
+ * `<contrib-group>` that holds no author, are left out, as those contributors are
166
+ * left out of `authors`. A group mixing authors and others keeps its direct
167
+ * `<aff>`s.
168
+ */
147
169
  function extractAffiliations(articleMeta) {
148
170
  if (!articleMeta)
149
171
  return [];
150
- const result = [];
151
- for (const aff of findAll(articleMeta, 'aff')) {
152
- const text = textContent(aff);
153
- if (text)
154
- result.push(text);
172
+ const affs = [];
173
+ for (const child of childrenOf(articleMeta)) {
174
+ const tag = tagNameOf(child);
175
+ if (tag === 'aff')
176
+ affs.push(child);
177
+ if (tag !== 'contrib-group')
178
+ continue;
179
+ const contribs = findAll(child, 'contrib');
180
+ const holdsAuthor = contribs.length === 0 || contribs.some(isAuthorContrib);
181
+ for (const member of childrenOf(child)) {
182
+ const memberTag = tagNameOf(member);
183
+ if (memberTag === 'aff' && holdsAuthor)
184
+ affs.push(member);
185
+ else if (memberTag === 'contrib' && isAuthorContrib(member)) {
186
+ affs.push(...findAll(member, 'aff'));
187
+ }
188
+ }
155
189
  }
156
- return result;
190
+ return [...new Set(affs.map(textContent).filter(Boolean))];
157
191
  }
158
192
  // ─── Journal & Publication Date ─────────────────────────────────────────────
159
193
  function extractJournal(journalMeta, articleMeta) {
@@ -210,6 +244,8 @@ function extractPubDate(articleMeta) {
210
244
  };
211
245
  }
212
246
  // ─── Abstract & Keywords ────────────────────────────────────────────────────
247
+ /** Closing punctuation a deposited section title can already end in. */
248
+ const TERMINAL_PUNCTUATION_RE = /[:.?!]$/;
213
249
  /**
214
250
  * Read the article's own abstract.
215
251
  *
@@ -226,6 +262,20 @@ function extractPubDate(articleMeta) {
226
262
  * abstract leaves its marker and its caption reaches `assets[]` alone —
227
263
  * {@link extractPmcAssets} already walks `<front>` — instead of concatenating
228
264
  * into the prose, and a `<list>` renders as list lines rather than a token run.
265
+ * A section that opens with a list starts it on the line below its
266
+ * `Title:` heading. (#202)
267
+ *
268
+ * A structured abstract is read child by child in source order: each `<sec>`
269
+ * as `Title: text`, and each run of content outside any `<sec>` as an untitled
270
+ * section reads — its statements space-joined, a blank line between it and the
271
+ * sections around it. A lead paragraph before the first section therefore
272
+ * opens the abstract as a paragraph of its own above the first `Title:` line.
273
+ * Springer deposits the whole abstract that way, ahead of a "Supplementary
274
+ * Information" section (PMC11281965), and reading the `<sec>`s alone returned
275
+ * that notice as the abstract. (#204)
276
+ *
277
+ * A section title deposited with its own closing punctuation (`Background:`,
278
+ * `Study Design.`, `What is known?`) keeps it in place of the added colon. (#205)
229
279
  */
230
280
  function extractAbstract(articleMeta) {
231
281
  if (!articleMeta)
@@ -234,33 +284,64 @@ function extractAbstract(articleMeta) {
234
284
  const abstractNode = abstracts.find((node) => !attrOf(node, 'abstract-type')) ?? abstracts[0];
235
285
  if (!abstractNode)
236
286
  return;
237
- const sections = findAll(abstractNode, 'sec');
238
- if (sections.length > 0) {
239
- const parts = [];
240
- for (const sec of sections) {
241
- const title = textContent(findOne(sec, 'title'));
242
- const text = abstractProse(sec);
243
- if (title && text)
244
- parts.push(`${title}: ${text}`);
245
- else if (text)
246
- parts.push(text);
287
+ const children = childrenOf(abstractNode);
288
+ if (!findOne(abstractNode, 'sec'))
289
+ return abstractProse(children).text || undefined;
290
+ const parts = [];
291
+ let outside = [];
292
+ const addOutside = () => {
293
+ const { text } = abstractProse(outside);
294
+ if (text)
295
+ parts.push(text);
296
+ outside = [];
297
+ };
298
+ for (const child of children) {
299
+ if (tagNameOf(child) !== 'sec') {
300
+ outside.push(child);
301
+ continue;
247
302
  }
248
- return parts.join('\n\n').trim() || undefined;
303
+ addOutside();
304
+ const title = textContent(findOne(child, 'title'));
305
+ const { ownLineFirst, text } = abstractProse(childrenOf(child));
306
+ const heading = TERMINAL_PUNCTUATION_RE.test(title) ? title : `${title}:`;
307
+ if (title && text)
308
+ parts.push(`${heading}${ownLineFirst ? '\n' : ' '}${text}`);
309
+ else if (text)
310
+ parts.push(text);
249
311
  }
250
- return abstractProse(abstractNode) || undefined;
312
+ addOutside();
313
+ return parts.join('\n\n').trim() || undefined;
251
314
  }
252
315
  /**
253
- * The prose of an `<abstract>` or one of its `<sec>`s: every child but the
254
- * heading the caller reports separately, space-joined. The single space is the
255
- * spacing the paragraph-only read this replaces already produced, so a record
256
- * depositing one untyped abstract of plain `<p>`s comes back byte-identical.
316
+ * The prose of a run of abstract content — an `<abstract>`'s or a `<sec>`'s
317
+ * children, or what an abstract holds outside its sections — leaving out the
318
+ * `<title>` and `<label>` the caller reports separately or not at all.
319
+ * Statements are space-joined — the spacing the paragraph-only read this
320
+ * replaces already produced, so a record depositing one untyped abstract of
321
+ * plain `<p>`s comes back byte-identical.
322
+ *
323
+ * A list reads on lines of its own, as it does in a body section: a blank line
324
+ * rather than a space separates it from the statement before and after it, so
325
+ * neither its first item runs onto the text before it nor its last item onto
326
+ * the heading or sentence after it (PMC8533648). A delimited box does the same,
327
+ * so its `[Box]` and `[End of box]` lines stand alone. `ownLineFirst` reports
328
+ * either on the first line, which the caller starts below the section's
329
+ * heading (PMC10869376). (#202, #210)
257
330
  */
258
- function abstractProse(node) {
259
- const prose = childrenOf(node).filter((child) => {
260
- const tag = tagNameOf(child);
331
+ function abstractProse(nodes) {
332
+ const prose = nodes.filter((node) => {
333
+ const tag = tagNameOf(node);
261
334
  return tag !== 'title' && tag !== 'label';
262
335
  });
263
- return flowBlocks(prose, STATEMENT_BLOCK_TAGS).join(' ');
336
+ const blocks = flowParts(prose, STATEMENT_BLOCK_TAGS);
337
+ let text = '';
338
+ for (const [i, block] of blocks.entries()) {
339
+ const previous = blocks[i - 1];
340
+ if (previous)
341
+ text += previous.ownLineLast || block.ownLineFirst ? '\n\n' : ' ';
342
+ text += block.text;
343
+ }
344
+ return { ownLineFirst: blocks[0]?.ownLineFirst ?? false, text };
264
345
  }
265
346
  function extractKeywords(articleMeta) {
266
347
  if (!articleMeta)
@@ -278,12 +359,18 @@ function extractKeywords(articleMeta) {
278
359
  // ─── Block Rendering ────────────────────────────────────────────────────────
279
360
  /** Indent one nesting level of a `<list>`/`<def-list>` nested in a `<list-item>`. */
280
361
  const NESTED_LIST_INDENT = ' ';
362
+ /** A {@link FlowBlock} whose edges both stand on their own lines (`ownLines`), or neither does. */
363
+ const flowBlock = (text, ownLines = false) => ({
364
+ text,
365
+ ownLineFirst: ownLines,
366
+ ownLineLast: ownLines,
367
+ });
281
368
  /** Close the prose run in progress, discarding it when it holds only whitespace. */
282
369
  function flushRun(flow) {
283
- const text = flow.run.replace(/\s+/g, ' ').trim();
370
+ const text = flow.run.toString().replace(/\s+/g, ' ').trim();
284
371
  if (text)
285
- flow.out.push(text);
286
- flow.run = '';
372
+ flow.out.push(flowBlock(text));
373
+ flow.run = new TextAssembler();
287
374
  }
288
375
  /**
289
376
  * Walk a mixed-content child list, accumulating inline text into a prose run and
@@ -299,20 +386,28 @@ function flushRun(flow) {
299
386
  * prose, {@link STATEMENT_BLOCK_TAGS} for a container whose `<title>` and `<p>`
300
387
  * children are separate statements rather than one continuous sentence.
301
388
  *
302
- * `<tex-math>` and `<alternatives>` are the two elements whose text is not the
303
- * concatenation of their subtree, so this walk defers to the shared rule rather
304
- * than reading their children one at a time: a `<tex-math>` contributes its
305
- * LaTeX document body via `rawTextContent`, and an `<alternatives>` re-enters
306
- * this walk with its single chosen child, which keeps a `<disp-formula>` found
307
- * there at block position. (#135)
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
393
+ * `<alternatives>` re-enters this walk with its single chosen child, which keeps
394
+ * a `<disp-formula>` found there at block position, and an `<institution-id>`
395
+ * in a funding statement contributes nothing. (#135, #208)
396
+ *
397
+ * The run is a {@link TextAssembler}, so prose follows the separator rules every
398
+ * 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)
308
401
  */
309
402
  function walkFlow(nodes, flow, blockTags) {
310
403
  for (const node of nodes) {
311
404
  if (isTextNode(node)) {
312
- flow.run += textOf(node);
405
+ flow.run.text(textOf(node));
313
406
  continue;
314
407
  }
315
408
  const tag = tagNameOf(node) ?? '';
409
+ if (IDENTIFIER_TAGS.has(tag))
410
+ continue;
316
411
  if (tag === 'alternatives') {
317
412
  const chosen = selectAlternative(node);
318
413
  if (chosen)
@@ -320,26 +415,32 @@ function walkFlow(nodes, flow, blockTags) {
320
415
  continue;
321
416
  }
322
417
  if (tag === 'tex-math') {
323
- flow.run += rawTextContent(node);
418
+ flow.run.text(rawTextContent(node));
324
419
  continue;
325
420
  }
326
421
  if (blockTags.has(tag)) {
327
422
  flushRun(flow);
328
- const rendered = renderBlock(node);
329
- if (rendered)
330
- flow.out.push(rendered);
423
+ const block = renderBlock(node);
424
+ if (block.text)
425
+ flow.out.push(block);
331
426
  continue;
332
427
  }
428
+ flow.run.open(tag);
333
429
  walkFlow(childrenOf(node), flow, blockTags);
430
+ flow.run.close(tag);
334
431
  }
335
432
  }
336
- /** Ordered text blocks contributed by one `<p>` — or any other flow container. */
337
- function flowBlocks(nodes, blockTags = BLOCK_TAGS) {
338
- const flow = { out: [], run: '' };
433
+ /** Ordered blocks contributed by one `<p>` — or any other flow container — with their list edges. */
434
+ function flowParts(nodes, blockTags = BLOCK_TAGS) {
435
+ const flow = { out: [], run: new TextAssembler() };
339
436
  walkFlow(nodes, flow, blockTags);
340
437
  flushRun(flow);
341
438
  return flow.out;
342
439
  }
440
+ /** Ordered text blocks contributed by one `<p>` — or any other flow container. */
441
+ function flowBlocks(nodes, blockTags = BLOCK_TAGS) {
442
+ return flowParts(nodes, blockTags).map((block) => block.text);
443
+ }
343
444
  /**
344
445
  * Block tags for a container whose `<title>` and `<p>` children each carry a
345
446
  * statement of their own — a `<caption>`, an `<abstract>`, an abstract `<sec>`.
@@ -369,30 +470,40 @@ function renderCaption(caption) {
369
470
  * sat; `<ref-list>` is carried by `references[]` and likewise contributes
370
471
  * nothing, which is what keeps a `<sec>` that only wraps one from surviving as
371
472
  * an empty "References" heading. (#116, #130)
473
+ *
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)
372
477
  */
373
478
  function renderBlock(node) {
374
479
  switch (tagNameOf(node)) {
375
480
  case 'table-wrap':
376
481
  case 'ref-list':
377
- return '';
482
+ return flowBlock('');
378
483
  case 'fig':
379
- return assetMarker(node, 'Figure');
484
+ return flowBlock(assetMarker(node, 'Figure'));
380
485
  case 'supplementary-material':
381
- return assetMarker(node, 'Supplementary');
486
+ return flowBlock(assetMarker(node, 'Supplementary'));
382
487
  case 'list':
383
- return renderList(node, 0);
488
+ return flowBlock(renderList(node, 0), true);
384
489
  case 'def-list':
385
- return renderDefList(node, 0);
490
+ return flowBlock(renderDefList(node, 0), true);
386
491
  case 'disp-quote':
387
- return renderDispQuote(node);
492
+ return flowBlock(renderDispQuote(node));
388
493
  case 'boxed-text':
389
- return renderBoxedText(node);
494
+ return flowBlock(renderFloatingBox(node), true);
390
495
  case 'preformat':
391
- return renderPreformat(node);
496
+ return flowBlock(renderPreformat(node));
392
497
  case 'disp-formula':
393
- return renderDispFormula(node);
394
- default:
395
- return flowBlocks(childrenOf(node)).join('\n\n');
498
+ return flowBlock(renderDispFormula(node));
499
+ default: {
500
+ const blocks = flowParts(childrenOf(node));
501
+ return {
502
+ text: blocks.map((block) => block.text).join('\n\n'),
503
+ ownLineFirst: blocks[0]?.ownLineFirst ?? false,
504
+ ownLineLast: blocks.at(-1)?.ownLineLast ?? false,
505
+ };
506
+ }
396
507
  }
397
508
  }
398
509
  /**
@@ -458,6 +569,25 @@ function assetMarker(node, kind) {
458
569
  * `<def-list>` nested inside a `<list-item>` — both in the JATS content model —
459
570
  * indents one level per depth. (#130)
460
571
  *
572
+ * Publishers nest a sub-list either as a child of the `<list-item>` or inside
573
+ * the item's `<p>` (PMC12892673), and both render the same way: below the
574
+ * item's line, one level deeper. Read as paragraph flow instead, the inner
575
+ * list's first item ran onto its parent's line. The item's `<p>` is split at
576
+ * the lists it holds, so the item reads in document order: prose before the
577
+ * first nested list stays on the item's line, and prose after one — the rest
578
+ * of that `<p>` or a later `<p>` — keeps its place below the list on a line of
579
+ * its own, one level deeper, rather than running onto the last nested item
580
+ * (PMC8533647) or moving above the list onto the item's line. An item whose
581
+ * text all follows its nested list still prints its marker, alone on the
582
+ * item's line, so that text stays under its own item rather than reading as
583
+ * the item before it; an item holding nothing but a nested list prints no line
584
+ * of its own. (#200, #203)
585
+ *
586
+ * A `simple` list prints no marker of its own, so an item's `<label>` is its
587
+ * only numbering and leads the item's line (`1. Cereals …`); dropping it lost
588
+ * the source numbering of every labeled simple list. Other list types print
589
+ * their own marker and leave the label out. (#185)
590
+ *
461
591
  * `withTitle: false` leaves the title out, for a list whose title has become
462
592
  * the heading of the section it forms (see {@link extractBodySections}).
463
593
  */
@@ -474,26 +604,64 @@ function renderList(list, depth, withTitle = true) {
474
604
  const marker = listType === 'simple' ? '' : listType === 'order' ? `${ordinal}. ` : '- ';
475
605
  const parts = [];
476
606
  const nested = [];
607
+ /** Prose read after a nested list, kept below that list. */
608
+ let after;
609
+ let proseAfter = false;
610
+ const flushAfter = () => {
611
+ const text = after?.filter(Boolean).join(' ');
612
+ if (!text)
613
+ return;
614
+ nested.push(`${pad}${NESTED_LIST_INDENT}${text}`);
615
+ proseAfter = true;
616
+ };
617
+ const addNested = (block) => {
618
+ flushAfter();
619
+ nested.push(block);
620
+ after = [];
621
+ };
477
622
  for (const child of childrenOf(item)) {
478
623
  const tag = tagNameOf(child);
479
- if (tag === 'label')
624
+ if (tag === 'label') {
625
+ if (listType === 'simple' && parts.length === 0)
626
+ parts.push(textContent(child));
480
627
  continue;
481
- if (tag === 'list')
482
- nested.push(renderList(child, depth + 1));
483
- else if (tag === 'def-list')
484
- nested.push(renderDefList(child, depth + 1));
485
- else
486
- parts.push(...flowBlocks([child]));
628
+ }
629
+ // A `<p>` is read child by child, so a list inside it splits its prose;
630
+ // any other child is one unit, itself a nested list or not.
631
+ let prose = [];
632
+ for (const node of tag === 'p' ? childrenOf(child) : [child]) {
633
+ const inner = renderNestedList(node, depth + 1);
634
+ if (inner === undefined) {
635
+ prose.push(node);
636
+ continue;
637
+ }
638
+ (after ?? parts).push(...flowBlocks(prose));
639
+ prose = [];
640
+ addNested(inner);
641
+ }
642
+ (after ?? parts).push(...flowBlocks(prose));
487
643
  }
488
- const text = parts.join(' ');
644
+ flushAfter();
645
+ const text = parts.filter(Boolean).join(' ');
489
646
  if (text)
490
647
  lines.push(`${pad}${marker}${text}`);
648
+ else if (proseAfter && marker)
649
+ lines.push(`${pad}${marker.trimEnd()}`);
491
650
  for (const block of nested)
492
651
  if (block)
493
652
  lines.push(block);
494
653
  }
495
654
  return lines.join('\n');
496
655
  }
656
+ /** A `<list>` or `<def-list>` rendered at `depth`; undefined for any other node. (#200) */
657
+ function renderNestedList(node, depth) {
658
+ const tag = tagNameOf(node);
659
+ if (tag === 'list')
660
+ return renderList(node, depth);
661
+ if (tag === 'def-list')
662
+ return renderDefList(node, depth);
663
+ return;
664
+ }
497
665
  /**
498
666
  * Render a `<def-list>`: its title on a line of its own, then
499
667
  * `- term — definition` per item. `withTitle: false` leaves the title out, as
@@ -541,9 +709,10 @@ function renderDispQuote(quote) {
541
709
  *
542
710
  * `withTitle: false` leaves out the `<caption>`'s `<title>` — the box's title in
543
711
  * the JATS content model — for a box whose title has become the heading of the
544
- * section it forms (see {@link extractBodySections}).
712
+ * section it forms (see {@link extractBodySections}). `withLabel: false` leaves
713
+ * out its `<label>`, for a box whose label {@link renderFloatingBox} names it by.
545
714
  */
546
- function renderBoxedText(boxedText, withTitle = true) {
715
+ function renderBoxedText(boxedText, { withLabel = true, withTitle = true } = {}) {
547
716
  const blocks = [];
548
717
  for (const child of childrenOf(boxedText)) {
549
718
  const tag = tagNameOf(child);
@@ -553,11 +722,38 @@ function renderBoxedText(boxedText, withTitle = true) {
553
722
  const parts = childrenOf(child).filter((c) => withTitle || tagNameOf(c) !== 'title');
554
723
  blocks.push(...flowBlocks(parts, STATEMENT_BLOCK_TAGS));
555
724
  }
556
- else
725
+ else if (withLabel || tag !== 'label')
557
726
  blocks.push(...flowBlocks([child]));
558
727
  }
559
728
  return blocks.join('\n\n');
560
729
  }
730
+ /** The line that closes a {@link renderFloatingBox} box. */
731
+ const BOX_END = '[End of box]';
732
+ /**
733
+ * Render a `<boxed-text>` that sits in the body flow — a "Box 1", a highlights
734
+ * panel, a sidebar — set apart from the prose around it. Read as plain
735
+ * paragraphs, nothing marked where the box ended, so the body text after it
736
+ * read as part of the box (PMC10666927). The box opens with a line naming it by
737
+ * its label and `<caption>` title (`[Box: Box 1. Virtual unconference format]`,
738
+ * or `[Box]` when it carries neither), its content follows as
739
+ * {@link renderBoxedText} reads it, and {@link BOX_END} closes it. A box with no
740
+ * readable content — a lone `<graphic>` — keeps only its opening line when it
741
+ * is named, and renders nothing when it is not. (#210)
742
+ *
743
+ * A box that forms a section of its own (see {@link extractBodySections})
744
+ * needs none of this: its title is the section heading, and the next heading
745
+ * ends it.
746
+ */
747
+ function renderFloatingBox(boxedText) {
748
+ const label = textContent(findOne(boxedText, 'label'));
749
+ const title = textContent(findOne(findOne(boxedText, 'caption'), 'title'));
750
+ const heading = [label, title].filter(Boolean).join(' ');
751
+ const opening = heading ? `[Box: ${heading}]` : '[Box]';
752
+ const content = renderBoxedText(boxedText, { withLabel: false, withTitle: false });
753
+ if (!content)
754
+ return heading ? opening : '';
755
+ return [opening, content, BOX_END].join('\n\n');
756
+ }
561
757
  /**
562
758
  * A `<sec>` subtree as flat text blocks: each heading on its own line above its
563
759
  * prose, descendants following in document order. Used where a section has no
@@ -722,9 +918,9 @@ function renderBlockWithoutTitle(node) {
722
918
  case 'list':
723
919
  return renderList(node, 0, false);
724
920
  case 'boxed-text':
725
- return renderBoxedText(node, false);
921
+ return renderBoxedText(node, { withTitle: false });
726
922
  default:
727
- return renderBlock(node);
923
+ return renderBlock(node).text;
728
924
  }
729
925
  }
730
926
  /**
@@ -849,7 +1045,9 @@ function parseTableWrap(tableWrap, sectionTitle) {
849
1045
  const id = attrOf(tableWrap, 'id');
850
1046
  const label = textContent(findOne(tableWrap, 'label')) || undefined;
851
1047
  const caption = renderCaption(findOne(tableWrap, 'caption'));
852
- const footnotes = textContent(findOne(tableWrap, 'table-wrap-foot')) || undefined;
1048
+ // One line per footnote or paragraph: a footer is a list of statements, and a
1049
+ // marker run into the previous note (`…dose.*No patient…`) misattributes it. (#185)
1050
+ const footnotes = lineTextContent(findOne(tableWrap, 'table-wrap-foot')) || undefined;
853
1051
  // Some deposits offer both renderings inside <alternatives>; prefer the markup.
854
1052
  const table = findOne(tableWrap, 'table') ?? findOne(findOne(tableWrap, 'alternatives'), 'table');
855
1053
  const { rows, headerRowCount } = table
@@ -940,7 +1138,8 @@ function extractTableRows(table) {
940
1138
  allHeaderCells = false;
941
1139
  sourceCells += 1;
942
1140
  col = drainCarried(row, col);
943
- const value = textContent(cell);
1141
+ // `\n` wherever the cell breaks a line: `1.08<break/>2.44` is two values. (#185)
1142
+ const value = lineTextContent(cell);
944
1143
  const rowspan = spanOf(cell, 'rowspan');
945
1144
  const width = Math.min(spanOf(cell, 'colspan'), MAX_TABLE_COLUMNS - col);
946
1145
  for (let i = 0; i < width; i++) {
@@ -1059,30 +1258,41 @@ function renderCitationName(node) {
1059
1258
  * direct children in document order, joining author names with `, ` and
1060
1259
  * labeling typed `<pub-id>`s, so the output reads as a citation rather than a
1061
1260
  * token run. (#69)
1261
+ *
1262
+ * Two field pairs are joined the way a citation prints them rather than by a
1263
+ * space, which read as two unrelated numbers: an `<fpage>` followed by its
1264
+ * `<lpage>` is a page range (`56–71`, `i1–i51`), and a `<volume>` followed by
1265
+ * its `<issue>` reads `16(1)`. Each token is kept verbatim. (#209)
1062
1266
  */
1063
1267
  function renderElementCitation(node) {
1064
1268
  const parts = [];
1269
+ /** The field element that contributed the last part; undefined after anything else. */
1270
+ let previous;
1065
1271
  for (const child of childrenOf(node)) {
1066
1272
  const tag = tagNameOf(child);
1273
+ let part;
1067
1274
  if (tag === 'person-group') {
1068
- const names = childrenOf(child).map(renderCitationName).filter(Boolean);
1069
- if (names.length > 0)
1070
- parts.push(names.join(', '));
1275
+ part = childrenOf(child).map(renderCitationName).filter(Boolean).join(', ');
1071
1276
  }
1072
1277
  else if (tag === 'pub-id') {
1073
1278
  const value = textContent(child);
1074
- if (value) {
1075
- const label = PUB_ID_LABELS[attrOf(child, 'pub-id-type') ?? ''];
1076
- parts.push(label ? `${label} ${value}` : value);
1077
- }
1279
+ const label = PUB_ID_LABELS[attrOf(child, 'pub-id-type') ?? ''];
1280
+ part = value && label ? `${label} ${value}` : value;
1078
1281
  }
1079
1282
  else {
1080
1283
  // Element field (source, volume, year, fpage, …) or a bare text node;
1081
1284
  // textContent renders both, and empty results drop out of the join.
1082
- const text = textContent(child);
1083
- if (text)
1084
- parts.push(text);
1285
+ part = textContent(child);
1085
1286
  }
1287
+ if (!part)
1288
+ continue;
1289
+ if (tag === 'lpage' && previous === 'fpage')
1290
+ parts.push(`${parts.pop()}–${part}`);
1291
+ else if (tag === 'issue' && previous === 'volume')
1292
+ parts.push(`${parts.pop()}(${part})`);
1293
+ else
1294
+ parts.push(part);
1295
+ previous = tag;
1086
1296
  }
1087
1297
  return parts.join(' ');
1088
1298
  }
@@ -1096,38 +1306,103 @@ function renderElementCitation(node) {
1096
1306
  */
1097
1307
  const NAME_WRAPPER_TAGS = new Set(['name', 'string-name', 'person-group']);
1098
1308
  /**
1099
- * Serialize a `<name>` / `<string-name>` / `<person-group>` subtree, applying
1100
- * the same rule `renderMixedCitation` applies to its own children: a single
1101
- * space between two adjacent elements that carry nothing between them, and
1102
- * source text emitted verbatim. `<person-group>` separates its names with real
1103
- * `, ` text nodes and `<string-name>` usually separates surname from given
1104
- * names with a newline, so a fixed separator would double punctuation the
1105
- * source already has — spacing only the zero-gap transitions leaves those
1106
- * records byte-identical. Returns raw text; the caller collapses whitespace
1107
- * once over the finished citation.
1309
+ * The members of an author list: each names one contributor, or (`<etal>`)
1310
+ * stands for the rest. Two of them with nothing but whitespace between them are
1311
+ * two list entries, which the source left for the reader to separate. (#209)
1108
1312
  */
1109
- function renderNameWrapper(node) {
1110
- let rendered = '';
1111
- let prevWasElement = false;
1313
+ const PERSON_TAGS = new Set(['name', 'string-name', 'collab', 'etal']);
1314
+ /** What an empty `<etal/>` stands for. (#209) */
1315
+ const SUPPLIED_ETAL = 'et al.';
1316
+ /** Any character other than whitespace. */
1317
+ const VISIBLE_TEXT = /\S/;
1318
+ /**
1319
+ * 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
1322
+ * `et al.` when it is empty — as nearly every deposit leaves it.
1323
+ */
1324
+ function renderCitationPart(child, tag, before) {
1325
+ if (tag === 'pub-id') {
1326
+ const value = textContent(child);
1327
+ const type = attrOf(child, 'pub-id-type') ?? '';
1328
+ const label = PUB_ID_LABELS[type];
1329
+ if (value && label && !hasLiteralIdPrefix(before, type)) {
1330
+ return { text: `${label} ${value}`, labeled: true, suppliedEtal: false };
1331
+ }
1332
+ return { text: value, labeled: false, suppliedEtal: false };
1333
+ }
1334
+ if (NAME_WRAPPER_TAGS.has(tag))
1335
+ return { ...renderCitationRun(child, true), labeled: false };
1336
+ const text = rawTextContent(child);
1337
+ if (tag === 'etal' && !text)
1338
+ return { text: SUPPLIED_ETAL, labeled: false, suppliedEtal: true };
1339
+ return { text, labeled: false, suppliedEtal: false };
1340
+ }
1341
+ /**
1342
+ * Serialize a `<mixed-citation>` (`wrapper: false`) or a `<name>` /
1343
+ * `<string-name>` / `<person-group>` inside one (`wrapper: true`), emitting
1344
+ * source text verbatim and writing a separator only where the source left
1345
+ * none:
1346
+ *
1347
+ * - Two adjacent author-list members ({@link PERSON_TAGS}) with nothing but
1348
+ * whitespace between them are joined by `, `: `Zwierenga F, van Veggel B,
1349
+ * Hendriks LEL, et al.` rather than `Zwierenga F van Veggel B Hendriks LEL`.
1350
+ * (#209)
1351
+ * - Any other two elements with nothing between them get one space: a
1352
+ * `<surname>` and its `<given-names>`, a title and the volume after it.
1353
+ * (#115, #123, #124)
1354
+ * - A label this renderer inserts is separated from whatever precedes it. (#115)
1355
+ * - A source `.` straight after a supplied `et al.` closes it rather than
1356
+ * doubling its period (`<etal/></person-group>. <article-title>`). (#209)
1357
+ *
1358
+ * Gaps the source already fills — `, ` text between names, a newline between a
1359
+ * `<string-name>`'s parts — are kept as written, so a fixed separator never
1360
+ * doubles punctuation the source has. Inside a wrapper an element that renders
1361
+ * nothing is skipped; at the citation's own level it still counts as the
1362
+ * element the next one follows. Returns raw text; the caller collapses
1363
+ * whitespace once over the finished citation.
1364
+ *
1365
+ * Whitespace is held in `gap` until what follows it decides whether it is
1366
+ * written, so no step re-reads the text already written and the cost stays
1367
+ * linear in the size of the citation.
1368
+ */
1369
+ function renderCitationRun(node, wrapper) {
1370
+ let text = '';
1371
+ /** Whitespace not yet written: the end of the last element's text, then source whitespace after it. */
1372
+ let gap = '';
1373
+ /** The element written last, while no visible source text has followed it. */
1374
+ let previous;
1375
+ let suppliedEtal = false;
1112
1376
  for (const child of childrenOf(node)) {
1113
1377
  if (isTextNode(child)) {
1114
- const raw = textOf(child);
1115
- if (raw) {
1116
- rendered += raw;
1117
- prevWasElement = false;
1378
+ let raw = textOf(child);
1379
+ if (!VISIBLE_TEXT.test(raw)) {
1380
+ gap += raw;
1381
+ continue;
1118
1382
  }
1383
+ if (suppliedEtal)
1384
+ raw = raw.replace(/^(\s*)\./, '$1');
1385
+ text += gap + raw;
1386
+ gap = '';
1387
+ previous = undefined;
1388
+ suppliedEtal = false;
1119
1389
  continue;
1120
1390
  }
1121
1391
  const tag = tagNameOf(child) ?? '';
1122
- const part = NAME_WRAPPER_TAGS.has(tag) ? renderNameWrapper(child) : rawTextContent(child);
1123
- if (!part)
1392
+ const part = renderCitationPart(child, tag, text);
1393
+ if (!part.text && (wrapper || tag === 'pub-id'))
1124
1394
  continue;
1125
- if (prevWasElement)
1126
- rendered += ' ';
1127
- rendered += part;
1128
- prevWasElement = true;
1395
+ if (previous && PERSON_TAGS.has(previous) && PERSON_TAGS.has(tag))
1396
+ text += ', ';
1397
+ else
1398
+ text += (previous && !gap) || part.labeled ? `${gap} ` : gap;
1399
+ const body = part.text.trimEnd();
1400
+ text += body;
1401
+ gap = part.text.slice(body.length);
1402
+ previous = tag;
1403
+ suppliedEtal = part.suppliedEtal;
1129
1404
  }
1130
- return rendered;
1405
+ return { text: text + gap, suppliedEtal };
1131
1406
  }
1132
1407
  /**
1133
1408
  * True when the citation text emitted so far already ends with a literal prefix
@@ -1158,49 +1433,22 @@ function hasLiteralIdPrefix(rendered, pubIdType) {
1158
1433
  * styles routinely close the prose on punctuation (`… (2020).`, `…021]`) and a
1159
1434
  * label butted against that reads as one token. Source transitions that already
1160
1435
  * carry punctuation or whitespace are emitted unchanged; the trailing whitespace
1161
- * collapse keeps a whitespace-only gap at one space.
1436
+ * collapse keeps a whitespace-only gap at one space. An author list whose names
1437
+ * the source left unseparated reads with `, ` between them and `et al.` for an
1438
+ * empty `<etal/>` (see {@link renderCitationRun}). (#209)
1162
1439
  */
1163
1440
  function renderMixedCitation(node) {
1164
- let rendered = '';
1165
- let prevWasElement = false;
1166
- for (const child of childrenOf(node)) {
1167
- if (isTextNode(child)) {
1168
- const raw = textOf(child);
1169
- if (raw) {
1170
- rendered += raw;
1171
- prevWasElement = false;
1172
- }
1173
- continue;
1174
- }
1175
- const tag = tagNameOf(child) ?? '';
1176
- let part;
1177
- let labeled = false;
1178
- if (tag === 'pub-id') {
1179
- const value = textContent(child);
1180
- if (!value)
1181
- continue;
1182
- const type = attrOf(child, 'pub-id-type') ?? '';
1183
- const label = PUB_ID_LABELS[type];
1184
- if (label && !hasLiteralIdPrefix(rendered, type)) {
1185
- labeled = true;
1186
- part = `${label} ${value}`;
1187
- }
1188
- else {
1189
- part = value;
1190
- }
1191
- }
1192
- else if (NAME_WRAPPER_TAGS.has(tag)) {
1193
- part = renderNameWrapper(child);
1194
- }
1195
- else {
1196
- part = rawTextContent(child);
1197
- }
1198
- if (prevWasElement || labeled)
1199
- rendered += ' ';
1200
- rendered += part;
1201
- prevWasElement = true;
1202
- }
1203
- return rendered.replace(/\s+/g, ' ').trim();
1441
+ return renderCitationRun(node, false).text.replace(/\s+/g, ' ').trim();
1442
+ }
1443
+ /**
1444
+ * A `<ref>`'s `<label>`, without the one pair of square brackets some deposits
1445
+ * print it in: `[19]` is reference 19, and the tool already writes each
1446
+ * reference's label and id inside brackets of its own (`[19 R19]`, not
1447
+ * `[[19] R19]`). Any other label — `1.`, `(3)`, `A12` — is kept as printed.
1448
+ * (#209)
1449
+ */
1450
+ function referenceLabel(label) {
1451
+ return textContent(label).replace(/^\[([^[\]]+)\]$/, '$1') || undefined;
1204
1452
  }
1205
1453
  /**
1206
1454
  * Extract references from anywhere under `root` — pass the `<article>` node to
@@ -1247,7 +1495,7 @@ export function extractReferences(root) {
1247
1495
  continue;
1248
1496
  if (id)
1249
1497
  seenIds.add(id);
1250
- const label = textContent(findOne(ref, 'label')) || undefined;
1498
+ const label = referenceLabel(findOne(ref, 'label'));
1251
1499
  results.push({
1252
1500
  ...(id && { id }),
1253
1501
  ...(label && { label }),