@cyanheads/pubmed-mcp-server 2.10.19 → 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.
- package/AGENTS.md +7 -4
- package/CLAUDE.md +7 -4
- package/Dockerfile +12 -3
- package/README.md +7 -7
- package/changelog/2.10.x/2.10.20.md +63 -0
- package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_text.js +10 -5
- package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +28 -10
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +27 -9
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +577 -485
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +5 -5
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +12 -8
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +11 -7
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +70 -34
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +2 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +11 -3
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +18 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +97 -13
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -3
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +7 -5
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +6 -4
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +33 -11
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +2 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +5 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +1 -1
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +1 -1
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.d.ts +24 -0
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +147 -46
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +16 -1
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +115 -70
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.js +38 -29
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.d.ts +9 -0
- package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.js +26 -2
- package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +395 -147
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +73 -5
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +223 -27
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.d.ts +8 -6
- package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.js +95 -19
- package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +20 -12
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +112 -19
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +2 -2
- package/dist/services/openalex/openalex-service.d.ts +10 -6
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +13 -9
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/package.json +13 -11
- 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
|
-
|
|
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
|
|
151
|
-
for (const
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
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
|
|
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
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
+
addOutside();
|
|
313
|
+
return parts.join('\n\n').trim() || undefined;
|
|
251
314
|
}
|
|
252
315
|
/**
|
|
253
|
-
* The prose of an `<abstract>` or
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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(
|
|
259
|
-
const prose =
|
|
260
|
-
const tag = tagNameOf(
|
|
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
|
-
|
|
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
|
|
303
|
-
* concatenation of their subtree, so this walk defers to
|
|
304
|
-
* than reading their children one at a time: a
|
|
305
|
-
* LaTeX document body via `rawTextContent`,
|
|
306
|
-
* this walk with its single chosen child, which keeps
|
|
307
|
-
* there at block position
|
|
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
|
|
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
|
|
418
|
+
flow.run.text(rawTextContent(node));
|
|
324
419
|
continue;
|
|
325
420
|
}
|
|
326
421
|
if (blockTags.has(tag)) {
|
|
327
422
|
flushRun(flow);
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
330
|
-
flow.out.push(
|
|
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
|
|
337
|
-
function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1100
|
-
* the
|
|
1101
|
-
*
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1115
|
-
if (raw) {
|
|
1116
|
-
|
|
1117
|
-
|
|
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 =
|
|
1123
|
-
if (!part)
|
|
1392
|
+
const part = renderCitationPart(child, tag, text);
|
|
1393
|
+
if (!part.text && (wrapper || tag === 'pub-id'))
|
|
1124
1394
|
continue;
|
|
1125
|
-
if (
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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 =
|
|
1498
|
+
const label = referenceLabel(findOne(ref, 'label'));
|
|
1251
1499
|
results.push({
|
|
1252
1500
|
...(id && { id }),
|
|
1253
1501
|
...(label && { label }),
|