@behackl/citation-js-extras 0.2.1 → 0.4.0
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/README.md +79 -279
- package/dist/index.d.ts +92 -22
- package/dist/index.js +491 -222
- package/dist/math.d.ts +4 -0
- package/dist/math.js +17 -6
- package/dist/types.d.ts +143 -17
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import Cite from "citation-js";
|
|
3
|
+
import CSL from "citeproc";
|
|
3
4
|
import { MathProtector } from "./math.js";
|
|
5
|
+
/**
|
|
6
|
+
* Ready-made badges for the identifiers of mathematical bibliographies. Their
|
|
7
|
+
* matchers accept the spellings found in real exports (`doi:10…`,
|
|
8
|
+
* `https://doi.org/10…`, `arXiv:2301.00001v2`, `MR1234567`). Use them as they
|
|
9
|
+
* are, or spread one to change a property:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* badges: [{ ...badgePresets.doi, className: "badge" }, badgePresets.arxiv]
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The title link uses the same preset for these fields, so both always agree.
|
|
16
|
+
*/
|
|
17
|
+
export const badgePresets = Object.freeze({
|
|
18
|
+
doi: Object.freeze({
|
|
19
|
+
field: "doi",
|
|
20
|
+
label: "DOI",
|
|
21
|
+
url: "https://doi.org/$1",
|
|
22
|
+
match: /^(?:doi:\s*|https?:\/\/(?:dx\.)?doi\.org\/)?(10\.\S+)$/i,
|
|
23
|
+
}),
|
|
24
|
+
arxiv: Object.freeze({
|
|
25
|
+
field: "arxiv",
|
|
26
|
+
label: "arXiv",
|
|
27
|
+
url: "https://arxiv.org/abs/$1",
|
|
28
|
+
match: /^(?:arxiv:\s*)?(.+?)(?:v\d+)?$/i,
|
|
29
|
+
}),
|
|
30
|
+
mrnumber: Object.freeze({
|
|
31
|
+
field: "mrnumber",
|
|
32
|
+
label: "MR",
|
|
33
|
+
url: "https://mathscinet.ams.org/mathscinet-getitem?mr=$1",
|
|
34
|
+
match: /^(?:MR\s*)?(\d+)/i,
|
|
35
|
+
}),
|
|
36
|
+
zbl: Object.freeze({
|
|
37
|
+
field: "zbl",
|
|
38
|
+
label: "zbMATH",
|
|
39
|
+
url: "https://zbmath.org/?q=an:$1",
|
|
40
|
+
match: /^(?:Zbl\s*)?(\d+\.\d+)/i,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
const DEFAULT_TITLE_LINK = ["url", "doi", "arxiv"];
|
|
44
|
+
const FORMAT_DEFAULT_KEYS = [
|
|
45
|
+
"titleLink", "badges", "linkifyUrls", "itemAttributes", "badgeListClassName",
|
|
46
|
+
"printLinkedIdentifiers", "sanitize", "lang", "appendBadges", "linkAttributes",
|
|
47
|
+
"wrapVariable",
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* The render call in progress. Engines are shared between instances (creating
|
|
51
|
+
* one compiles the style, which is slow), so their callbacks look up whoever is
|
|
52
|
+
* rendering right now. Rendering is synchronous, which makes this safe.
|
|
53
|
+
*/
|
|
54
|
+
let current;
|
|
55
|
+
/** Engines by style and locale, like citation-js's own cache. */
|
|
56
|
+
const engines = new Map();
|
|
57
|
+
const busy = new Set();
|
|
58
|
+
function createEngine(templateName, lang) {
|
|
59
|
+
const { templates, locales } = Cite.plugins.config.get("@csl");
|
|
60
|
+
const engine = new CSL.Engine({
|
|
61
|
+
retrieveItem: (id) => {
|
|
62
|
+
const item = current?.items.get(id);
|
|
63
|
+
if (!item)
|
|
64
|
+
throw new Error(`Cannot find entry with id '${id}'`);
|
|
65
|
+
return item;
|
|
66
|
+
},
|
|
67
|
+
retrieveLocale: (locale) => locales.get(locale) ?? locales.get(locale.replace("-", "_")) ?? {},
|
|
68
|
+
variableWrapper: (params, pre, str, post) => current ? current.wrap(params, pre, str, post) : pre + str + post,
|
|
69
|
+
}, templates.get(templateName), locales.has(lang) ? lang : undefined, true);
|
|
70
|
+
// As citation-js: DOIs and URLs are linked by us, not by citeproc.
|
|
71
|
+
engine.opt.development_extensions.wrap_url_and_doi = false;
|
|
72
|
+
return engine;
|
|
73
|
+
}
|
|
4
74
|
// ---------------------------------------------------------------------------
|
|
5
75
|
// Bibliography class
|
|
6
76
|
// ---------------------------------------------------------------------------
|
|
@@ -10,25 +80,34 @@ export class Bibliography {
|
|
|
10
80
|
/** All parsed entries. */
|
|
11
81
|
entries;
|
|
12
82
|
customFieldNames;
|
|
83
|
+
bibtexEntries = new Map();
|
|
13
84
|
math;
|
|
14
85
|
/** Formatting defaults from the constructor; each call may override them. */
|
|
15
86
|
formatDefaults;
|
|
87
|
+
rendering;
|
|
16
88
|
constructor(options) {
|
|
17
89
|
const bibData = maybeReadFile(options.data);
|
|
18
90
|
this.customFieldNames = options.customFields ?? [];
|
|
19
|
-
this.formatDefaults = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
91
|
+
this.formatDefaults = {};
|
|
92
|
+
for (const key of FORMAT_DEFAULT_KEYS) {
|
|
93
|
+
if (options[key] !== undefined)
|
|
94
|
+
this.formatDefaults[key] = options[key];
|
|
95
|
+
}
|
|
24
96
|
// Register CSL style
|
|
25
97
|
this.templateName = this.registerStyle(options.cslStyle);
|
|
26
98
|
// Two-pass parse: raw (preserves all fields) + CSL (for formatting)
|
|
27
99
|
const { plugins } = Cite;
|
|
28
100
|
const rawEntries = plugins.input.chainLink(bibData);
|
|
29
|
-
const
|
|
101
|
+
const duplicates = new Set();
|
|
30
102
|
for (const entry of rawEntries) {
|
|
31
|
-
|
|
103
|
+
if (this.bibtexEntries.has(entry.label))
|
|
104
|
+
duplicates.add(entry.label);
|
|
105
|
+
this.bibtexEntries.set(entry.label, entry);
|
|
106
|
+
}
|
|
107
|
+
// Raw fields are merged by key, so a duplicate would silently give one
|
|
108
|
+
// entry the other's fields.
|
|
109
|
+
if (duplicates.size) {
|
|
110
|
+
throw new Error(`Duplicate citation key${duplicates.size > 1 ? "s" : ""}: ${[...duplicates].join(", ")}`);
|
|
32
111
|
}
|
|
33
112
|
// Protect resolved raw fields, after BibTeX strings/concatenations are parsed
|
|
34
113
|
// but before the lossy TeX-to-CSL conversion. Keep the original raw map intact.
|
|
@@ -41,11 +120,12 @@ export class Bibliography {
|
|
|
41
120
|
: new Cite(bibData);
|
|
42
121
|
this.entries = cite.data.map((csl) => {
|
|
43
122
|
const key = String(csl["citation-key"] || csl.id);
|
|
44
|
-
const raw =
|
|
123
|
+
const raw = this.bibtexEntries.get(key)?.properties ?? {};
|
|
45
124
|
const custom = {};
|
|
46
125
|
for (const f of this.customFieldNames) {
|
|
47
|
-
|
|
48
|
-
|
|
126
|
+
const value = raw[f.toLowerCase()] ?? raw[f];
|
|
127
|
+
if (value != null)
|
|
128
|
+
custom[f] = String(value);
|
|
49
129
|
}
|
|
50
130
|
return {
|
|
51
131
|
csl,
|
|
@@ -69,17 +149,19 @@ export class Bibliography {
|
|
|
69
149
|
return this.entries.filter((e) => Object.entries(criteria).every(([k, v]) => e.custom[k] === v));
|
|
70
150
|
}
|
|
71
151
|
/**
|
|
72
|
-
* Return a sorted **copy** of the given entries.
|
|
152
|
+
* Return a sorted **copy** of the given entries. Ties keep their input order.
|
|
73
153
|
*
|
|
74
154
|
* @param entries - entries to sort (not mutated)
|
|
75
|
-
* @param by - `'year'` (default)
|
|
155
|
+
* @param by - `'year'` (default), `'date'` (year, then month, then day; a
|
|
156
|
+
* missing part counts as 0), or a custom field name
|
|
76
157
|
* @param order - `'desc'` (default) or `'asc'`
|
|
77
158
|
*/
|
|
78
159
|
sort(entries, { by = "year", order = "desc" } = {}) {
|
|
160
|
+
const keyOf = (e) => by === "year" ? [e.year ?? 0]
|
|
161
|
+
: by === "date" ? issuedParts(e)
|
|
162
|
+
: [e.custom[by] ?? ""];
|
|
79
163
|
return [...entries].sort((a, b) => {
|
|
80
|
-
const
|
|
81
|
-
const vb = by === "year" ? (b.year ?? 0) : (b.custom[by] ?? "");
|
|
82
|
-
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
|
164
|
+
const cmp = compareKeys(keyOf(a), keyOf(b));
|
|
83
165
|
return order === "desc" ? -cmp : cmp;
|
|
84
166
|
});
|
|
85
167
|
}
|
|
@@ -92,80 +174,179 @@ export class Bibliography {
|
|
|
92
174
|
*/
|
|
93
175
|
formatEntry(entry, options = {}) {
|
|
94
176
|
const merged = this.mergeOptions(options);
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const innerHtml = unwrapCslEntry(raw) ?? raw.trim();
|
|
98
|
-
return this.buildEntryHtml(entry, innerHtml, merged);
|
|
177
|
+
const [html = ""] = this.renderItems([entry], merged);
|
|
178
|
+
return this.finish(html, merged);
|
|
99
179
|
}
|
|
100
180
|
/**
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
181
|
+
* Format a list of entries as a complete HTML bibliography.
|
|
182
|
+
*/
|
|
183
|
+
formatHtml(entries, options = {}) {
|
|
184
|
+
if (entries.length === 0)
|
|
185
|
+
return "";
|
|
186
|
+
const merged = this.mergeOptions(options);
|
|
187
|
+
const tag = merged.list ?? "ol";
|
|
188
|
+
const listAttributes = merged.listAttributes ?? (tag === "ol" ? { reversed: true } : {});
|
|
189
|
+
const itemTag = tag === "div" ? "div" : "li";
|
|
190
|
+
// Rendered in one citeproc run so style-dependent numbering/state
|
|
191
|
+
// (e.g. vancouver left-margin labels) remains correct.
|
|
192
|
+
const items = this.renderItems(entries, merged).map((inner, index) => {
|
|
193
|
+
const entry = entries[index];
|
|
194
|
+
const attributes = withClass({ "data-csl-entry-id": entry.key, ...(merged.itemAttributes && attributed(entry, () => merged.itemAttributes(entry))) }, "csl-entry");
|
|
195
|
+
return `<${itemTag}${renderAttributes(attributes)}>${inner}</${itemTag}>`;
|
|
196
|
+
});
|
|
197
|
+
const list = `<${tag}${renderAttributes(withClass(listAttributes, "csl-bib-body"))}>\n${items.join("\n")}\n</${tag}>`;
|
|
198
|
+
return this.finish(list, merged);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The links an entry gets with these options: its title link and badges.
|
|
202
|
+
* The same resolution the formatted HTML uses, so the two always agree; for
|
|
203
|
+
* rendering badges yourself, pair it with `appendBadges: false`.
|
|
204
|
+
*/
|
|
205
|
+
links(entry, options = {}) {
|
|
206
|
+
return this.resolveLinks(entry, this.mergeOptions(options));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The entry as BibTeX that stands on its own, e.g. for readers to copy:
|
|
210
|
+
* `@string` abbreviations are resolved and the fields of `crossref` parents
|
|
211
|
+
* filled in using biblatex's title-remapping rules. Missing parents leave
|
|
212
|
+
* `crossref` unresolved, so the copy may still require its parent.
|
|
213
|
+
* Field values are the TeX of the `.bib` file. Requires this bibliography's
|
|
214
|
+
* original key and raw object; shallow entry copies are accepted.
|
|
215
|
+
*/
|
|
216
|
+
bibtex(entry, options = {}) {
|
|
217
|
+
const source = this.bibtexEntries.get(entry.key);
|
|
218
|
+
if (!source || source.properties !== entry.raw) {
|
|
219
|
+
throw new Error(`entry ${entry.key}: not in this bibliography`);
|
|
220
|
+
}
|
|
221
|
+
const { type } = source;
|
|
222
|
+
const fields = this.withInherited(type, entry.raw);
|
|
223
|
+
if (fields !== entry.raw)
|
|
224
|
+
delete fields.crossref;
|
|
225
|
+
const exclude = new Set((options.exclude ?? []).map(field => field.toLowerCase()));
|
|
226
|
+
const lines = Object.entries(fields)
|
|
227
|
+
.filter(([name, value]) => value != null && !exclude.has(name))
|
|
228
|
+
.map(([name, value]) => ` ${name} = ${bibtexValue(name, value)},`);
|
|
229
|
+
return [`@${type}{${entry.key},`, ...lines, "}"].join("\n");
|
|
230
|
+
}
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
// Private helpers
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
/**
|
|
235
|
+
* An entry's fields with those inherited through `crossref`, using the same
|
|
236
|
+
* biblatex title-remapping rules as citation-js (`plugin-bibtex`'s
|
|
237
|
+
* `mapping/crossref.js`). Keep the mapping and exclusion tests in sync.
|
|
107
238
|
*/
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
239
|
+
withInherited(type, raw) {
|
|
240
|
+
const parent = raw.crossref == null ? undefined : this.bibtexEntries.get(String(raw.crossref));
|
|
241
|
+
if (!parent || parent.properties === raw)
|
|
242
|
+
return raw;
|
|
243
|
+
const inherited = { ...this.withInherited(parent.type, parent.properties) };
|
|
244
|
+
for (const field of NOT_INHERITED)
|
|
245
|
+
delete inherited[field];
|
|
246
|
+
if ((parent.type === "mvbook" || parent.type === "book") && BOOK_PARTS.includes(type)) {
|
|
247
|
+
inherited.bookauthor = inherited.author;
|
|
248
|
+
}
|
|
249
|
+
const [prefix, children] = TITLE_INHERITANCE[parent.type] ?? [];
|
|
250
|
+
if (prefix && children.includes(type)) {
|
|
251
|
+
inherited[`${prefix}title`] = inherited.title;
|
|
252
|
+
inherited[`${prefix}subtitle`] = inherited.subtitle;
|
|
253
|
+
if (prefix !== "journal")
|
|
254
|
+
inherited[`${prefix}titleaddon`] = inherited.titleaddon;
|
|
255
|
+
for (const field of TITLE_FIELDS)
|
|
256
|
+
delete inherited[field];
|
|
257
|
+
}
|
|
258
|
+
const fields = { ...raw };
|
|
259
|
+
for (const [name, value] of Object.entries(inherited))
|
|
260
|
+
if (!Object.hasOwn(fields, name))
|
|
261
|
+
fields[name] = value;
|
|
262
|
+
return fields;
|
|
113
263
|
}
|
|
114
264
|
/** Per-call options win over the defaults given to the constructor. */
|
|
115
265
|
mergeOptions(options) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
266
|
+
const merged = { ...options };
|
|
267
|
+
for (const key of FORMAT_DEFAULT_KEYS) {
|
|
268
|
+
if (merged[key] === undefined)
|
|
269
|
+
merged[key] = this.formatDefaults[key];
|
|
270
|
+
}
|
|
271
|
+
return merged;
|
|
122
272
|
}
|
|
123
273
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
274
|
+
* Entry HTML without wrapper, shared by `formatEntry` and `formatHtml` so
|
|
275
|
+
* both honour the same options. Links are resolved first: they decide what
|
|
276
|
+
* the style may print and what is added afterwards.
|
|
127
277
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
278
|
+
renderItems(entries, options) {
|
|
279
|
+
const links = entries.map(entry => this.resolveLinks(entry, options));
|
|
280
|
+
const csl = entries.map((entry, index) => displayCsl(entry.csl, options.printLinkedIdentifiers ? [] : linkedFields(links[index])));
|
|
281
|
+
const byId = new Map(entries.map((entry, index) => [String(entry.csl.id ?? entry.key), { entry, links: links[index], titleLinked: false }]));
|
|
282
|
+
this.rendering = { options, byId };
|
|
283
|
+
let rendered;
|
|
131
284
|
try {
|
|
132
|
-
|
|
285
|
+
rendered = this.renderCslEntries(csl, options.lang ?? "en-US");
|
|
133
286
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
throw new Error(entry ? `entry ${entry.key}: ${message}` : message, { cause: error });
|
|
287
|
+
finally {
|
|
288
|
+
this.rendering = undefined;
|
|
137
289
|
}
|
|
290
|
+
const renderedMap = new Map(rendered);
|
|
291
|
+
return entries.map((entry, index) => {
|
|
292
|
+
const id = String(entry.csl.id ?? entry.key);
|
|
293
|
+
const raw = renderedMap.get(id) ?? rendered[index]?.[1] ?? "";
|
|
294
|
+
let html = unwrapCslEntry(raw) ?? raw.trim();
|
|
295
|
+
return decorate(html, byId.get(id).titleLinked, links[index], options);
|
|
296
|
+
});
|
|
138
297
|
}
|
|
139
298
|
/**
|
|
140
|
-
*
|
|
299
|
+
* Called by citeproc for every variable it renders. Everything the library
|
|
300
|
+
* adds to the citation text happens here, one variable at a time, instead of
|
|
301
|
+
* by searching the finished HTML:
|
|
302
|
+
*
|
|
303
|
+
* - the title link, around the exact output of the title variable, so no
|
|
304
|
+
* quotes, markup or capitalisation the style applies can hide the title;
|
|
305
|
+
* - bare URLs in a variable (e.g. in a `note`) become links. Formulas are
|
|
306
|
+
* still placeholders here, so MathML's xmlns URL is never linked;
|
|
307
|
+
* - the consumer's `wrapVariable`, outermost.
|
|
141
308
|
*/
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
309
|
+
wrapVariable(params, pre, str, post) {
|
|
310
|
+
const state = this.rendering;
|
|
311
|
+
const item = state?.byId.get(String(params.itemData?.id));
|
|
312
|
+
if (!state || !item || params.context !== "bibliography" || params.mode !== "html" || !str) {
|
|
313
|
+
return pre + str + post;
|
|
314
|
+
}
|
|
315
|
+
const variable = params.variableNames?.[0] ?? "";
|
|
316
|
+
let html = str;
|
|
317
|
+
// A style may print the title in another variable's place, e.g. APA
|
|
318
|
+
// substitutes it for missing authors; citeproc then doesn't call it `title`.
|
|
319
|
+
const isTitle = variable === "title"
|
|
320
|
+
|| (typeof item.entry.csl.title === "string" && plainText(str) === plainText(item.entry.csl.title));
|
|
321
|
+
if (isTitle && item.links.title && !item.titleLinked) {
|
|
322
|
+
html = linkHtml(item.links.title, html, state.options);
|
|
323
|
+
item.titleLinked = true;
|
|
324
|
+
}
|
|
325
|
+
if (state.options.linkifyUrls !== false)
|
|
326
|
+
html = linkifyBareUrls(html);
|
|
327
|
+
const wrap = state.options.wrapVariable;
|
|
328
|
+
if (wrap)
|
|
329
|
+
html = attributed(item.entry, () => wrap(html, { variable, entry: item.entry }));
|
|
330
|
+
return pre + html + post;
|
|
331
|
+
}
|
|
332
|
+
/** Sanitize while formulas are placeholders, then insert rendered math. */
|
|
333
|
+
finish(html, options) {
|
|
334
|
+
const safe = options.sanitize ? options.sanitize(html) : html;
|
|
335
|
+
return this.math ? this.math.restore(safe, options.renderMath) : safe;
|
|
336
|
+
}
|
|
337
|
+
resolveLinks(entry, options) {
|
|
338
|
+
const badges = options.badges ?? [];
|
|
339
|
+
const rendered = [];
|
|
340
|
+
for (const badge of badges) {
|
|
341
|
+
for (const link of applyBadge(entry, badge)) {
|
|
342
|
+
rendered.push({ kind: "badge", field: badge.field, ...link, className: badge.className, entry });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const title = typeof entry.csl.title === "string" && entry.csl.title.trim()
|
|
346
|
+
? resolveTitleLink(entry, options.titleLink ?? DEFAULT_TITLE_LINK, badges)
|
|
347
|
+
: undefined;
|
|
348
|
+
return title ? { title, badges: rendered } : { badges: rendered };
|
|
165
349
|
}
|
|
166
|
-
// -------------------------------------------------------------------------
|
|
167
|
-
// Private helpers
|
|
168
|
-
// -------------------------------------------------------------------------
|
|
169
350
|
registerStyle(cslStyle) {
|
|
170
351
|
if (!cslStyle)
|
|
171
352
|
return "apa";
|
|
@@ -184,127 +365,41 @@ export class Bibliography {
|
|
|
184
365
|
}
|
|
185
366
|
return name;
|
|
186
367
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
let out = html;
|
|
204
|
-
// Link the title text
|
|
205
|
-
const titleUrl = this.resolveTitleLink(entry, options.titleLink);
|
|
206
|
-
const title = entry.csl.title;
|
|
207
|
-
if (titleUrl && typeof title === "string" && title.trim()) {
|
|
208
|
-
out = this.linkTitle(out, title, titleUrl);
|
|
209
|
-
}
|
|
210
|
-
// Append badges
|
|
211
|
-
const badges = options.badges ?? [];
|
|
212
|
-
const badgeHtml = this.renderBadges(entry, badges);
|
|
213
|
-
if (badgeHtml) {
|
|
214
|
-
out += ` ${badgeHtml}`;
|
|
215
|
-
}
|
|
216
|
-
return out;
|
|
217
|
-
}
|
|
218
|
-
resolveTitleLink(entry, fields) {
|
|
219
|
-
const order = fields ?? ["url", "doi", "arxiv"];
|
|
220
|
-
for (const field of order) {
|
|
221
|
-
const value = entry.raw[field] ?? entry.csl[field.toUpperCase()] ?? entry.csl[field];
|
|
222
|
-
if (!value)
|
|
223
|
-
continue;
|
|
224
|
-
const raw = String(value).trim();
|
|
225
|
-
if (!raw)
|
|
226
|
-
continue;
|
|
227
|
-
if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw)) {
|
|
228
|
-
return sanitizeUrl(raw);
|
|
229
|
-
}
|
|
230
|
-
if (field === "doi") {
|
|
231
|
-
const doi = raw.replace(/^doi:\s*/i, "");
|
|
232
|
-
return sanitizeUrl(`https://doi.org/${doi}`);
|
|
233
|
-
}
|
|
234
|
-
if (field === "arxiv") {
|
|
235
|
-
return sanitizeUrl(`https://arxiv.org/abs/${raw.replace(/v\d+$/, "")}`);
|
|
236
|
-
}
|
|
368
|
+
/**
|
|
369
|
+
* Render entries with citeproc, as `cite.format("bibliography")` does, but
|
|
370
|
+
* through an engine that has our variable wrapper: citeproc only accepts it
|
|
371
|
+
* when the engine is created. Styles and locales come from citation-js's
|
|
372
|
+
* registries, and the data is prepared the same way.
|
|
373
|
+
*/
|
|
374
|
+
renderCslEntries(csl, lang) {
|
|
375
|
+
const data = Cite.util.downgradeCsl(csl);
|
|
376
|
+
const key = `${this.templateName}|${lang}`;
|
|
377
|
+
let engine = engines.get(key);
|
|
378
|
+
// citeproc is not re-entrant: a consumer function that formats with the
|
|
379
|
+
// same style while this engine renders gets an engine of its own.
|
|
380
|
+
if (!engine || busy.has(engine)) {
|
|
381
|
+
engine = createEngine(this.templateName, lang);
|
|
382
|
+
if (!engines.has(key))
|
|
383
|
+
engines.set(key, engine);
|
|
237
384
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
for (const token of tokens) {
|
|
252
|
-
if (token.startsWith("<")) {
|
|
253
|
-
const lower = token.toLowerCase();
|
|
254
|
-
if (/^<a\b/.test(lower))
|
|
255
|
-
insideAnchor = true;
|
|
256
|
-
if (/^<\/a\b/.test(lower))
|
|
257
|
-
insideAnchor = false;
|
|
258
|
-
if (/^<script\b/.test(lower))
|
|
259
|
-
insideScript = true;
|
|
260
|
-
if (/^<\/script\b/.test(lower))
|
|
261
|
-
insideScript = false;
|
|
262
|
-
if (/^<style\b/.test(lower))
|
|
263
|
-
insideStyle = true;
|
|
264
|
-
if (/^<\/style\b/.test(lower))
|
|
265
|
-
insideStyle = false;
|
|
266
|
-
output.push(token);
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
if (linked || insideAnchor || insideScript || insideStyle) {
|
|
270
|
-
output.push(token);
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
const replaced = token.replace(regex, (match) => {
|
|
274
|
-
linked = true;
|
|
275
|
-
return `<a href="${escapeAttr(url)}">${match}</a>`;
|
|
276
|
-
});
|
|
277
|
-
output.push(replaced);
|
|
385
|
+
const previous = current;
|
|
386
|
+
current = {
|
|
387
|
+
items: new Map(data.map(item => [String(item.id), item])),
|
|
388
|
+
wrap: (params, pre, str, post) => this.wrapVariable(params, pre, str, post),
|
|
389
|
+
};
|
|
390
|
+
busy.add(engine);
|
|
391
|
+
try {
|
|
392
|
+
engine.updateItems([]);
|
|
393
|
+
const ids = engine.updateItems(data.map(item => String(item.id)), true);
|
|
394
|
+
const bibliography = engine.makeBibliography();
|
|
395
|
+
if (!bibliography)
|
|
396
|
+
return [];
|
|
397
|
+
return bibliography[1].map((html, index) => [String(ids[index]), html]);
|
|
278
398
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const parts = [];
|
|
283
|
-
for (const badge of badges) {
|
|
284
|
-
const rawValue = entry.raw[badge.field] ?? entry.custom[badge.field];
|
|
285
|
-
if (rawValue == null)
|
|
286
|
-
continue;
|
|
287
|
-
const strValue = String(rawValue);
|
|
288
|
-
let insertValue;
|
|
289
|
-
if (badge.match) {
|
|
290
|
-
const m = strValue.match(badge.match);
|
|
291
|
-
if (!m)
|
|
292
|
-
continue;
|
|
293
|
-
insertValue = m[1] ?? m[0];
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
insertValue = strValue;
|
|
297
|
-
}
|
|
298
|
-
const unsafeUrl = badge.url.replace(/\$1/g, insertValue);
|
|
299
|
-
const safeUrl = sanitizeUrl(unsafeUrl);
|
|
300
|
-
if (!safeUrl)
|
|
301
|
-
continue;
|
|
302
|
-
const cls = badge.className ? ` class="${escapeAttr(badge.className)}"` : "";
|
|
303
|
-
parts.push(`<a${cls} href="${escapeAttr(safeUrl)}">${escapeHtml(String(badge.label))}</a>`);
|
|
399
|
+
finally {
|
|
400
|
+
busy.delete(engine);
|
|
401
|
+
current = previous;
|
|
304
402
|
}
|
|
305
|
-
if (parts.length === 0)
|
|
306
|
-
return "";
|
|
307
|
-
return `<span class="bib-links">${parts.join(" ")}</span>`;
|
|
308
403
|
}
|
|
309
404
|
}
|
|
310
405
|
// ---------------------------------------------------------------------------
|
|
@@ -353,6 +448,184 @@ export function linkifyBareUrls(html) {
|
|
|
353
448
|
return output.join("");
|
|
354
449
|
}
|
|
355
450
|
// ---------------------------------------------------------------------------
|
|
451
|
+
// Fields and links
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
/**
|
|
454
|
+
* Biblatex's non-inherited metadata. Intentionally uses `shorthand` and
|
|
455
|
+
* `shorthandintro`: plugin-bibtex 0.7.21 misspells these as `shortand` and
|
|
456
|
+
* `shortandintro`, so this exclusion differs from that version's implementation.
|
|
457
|
+
*/
|
|
458
|
+
const NOT_INHERITED = [
|
|
459
|
+
"ids", "crossref", "xref", "entryset", "entrysubtype", "execute", "label", "options", "presort",
|
|
460
|
+
"related", "relatedoptions", "relatedstring", "relatedtype", "shorthand", "shorthandintro", "sortkey",
|
|
461
|
+
];
|
|
462
|
+
const TITLE_FIELDS = ["title", "subtitle", "titleaddon", "shorttitle", "sorttitle", "indextitle", "indexsorttitle"];
|
|
463
|
+
const BOOK_PARTS = ["inbook", "bookinbook", "suppbook"];
|
|
464
|
+
const COLLECTION_PARTS = ["incollection", "inreference", "suppcollection"];
|
|
465
|
+
/** A parent's `title` becomes `<prefix>title` in children of these types. */
|
|
466
|
+
const TITLE_INHERITANCE = {
|
|
467
|
+
mvbook: ["main", ["book", ...BOOK_PARTS]],
|
|
468
|
+
mvcollection: ["main", ["collection", "reference", ...COLLECTION_PARTS]],
|
|
469
|
+
mvreference: ["main", ["collection", "reference", ...COLLECTION_PARTS]],
|
|
470
|
+
mvproceedings: ["main", ["proceedings", "inproceedings"]],
|
|
471
|
+
book: ["book", BOOK_PARTS],
|
|
472
|
+
collection: ["book", COLLECTION_PARTS],
|
|
473
|
+
reference: ["book", COLLECTION_PARTS],
|
|
474
|
+
proceedings: ["book", ["inproceedings"]],
|
|
475
|
+
periodical: ["journal", ["article", "suppperiodical"]],
|
|
476
|
+
};
|
|
477
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
478
|
+
/** A field value in braces, with the whitespace BibTeX would compress anyway compressed. */
|
|
479
|
+
function bibtexValue(name, value) {
|
|
480
|
+
const text = String(value).replace(/\s*\n\s*/g, " ");
|
|
481
|
+
// citation-js reads `month = mar` as "03"; the abbreviation is what BibTeX styles expect.
|
|
482
|
+
if (name === "month" && /^(?:0[1-9]|1[0-2])$/.test(text))
|
|
483
|
+
return MONTHS[Number(text) - 1];
|
|
484
|
+
return `{${text}}`;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The value of a BibTeX field, and the only place fields are read. Names are
|
|
488
|
+
* case-insensitive. A field named like an eprint archive falls back to
|
|
489
|
+
* `eprint` when `eprinttype`/`archivePrefix` names that archive (biblatex and
|
|
490
|
+
* arXiv exports). Last, the CSL variable, which covers values citation-js
|
|
491
|
+
* derives (e.g. `URL` from `howpublished = {\url{…}}`).
|
|
492
|
+
*/
|
|
493
|
+
function fieldValue(entry, field) {
|
|
494
|
+
const name = field.toLowerCase();
|
|
495
|
+
let value = entry.raw[name] ?? entry.raw[field];
|
|
496
|
+
if (value == null) {
|
|
497
|
+
const archive = entry.raw.eprinttype ?? entry.raw.archiveprefix;
|
|
498
|
+
if (archive != null && String(archive).trim().toLowerCase() === name)
|
|
499
|
+
value = entry.raw.eprint;
|
|
500
|
+
}
|
|
501
|
+
value ??= entry.csl[field.toUpperCase()] ?? entry.csl[name];
|
|
502
|
+
const text = value == null ? "" : String(value).trim();
|
|
503
|
+
return text || undefined;
|
|
504
|
+
}
|
|
505
|
+
/** The links a badge yields for an entry: none, one, or one per `split` value. */
|
|
506
|
+
function applyBadge(entry, badge) {
|
|
507
|
+
const value = fieldValue(entry, badge.field);
|
|
508
|
+
if (value === undefined)
|
|
509
|
+
return [];
|
|
510
|
+
const values = badge.split
|
|
511
|
+
? value.split(badge.split).map(part => part.trim()).filter(Boolean)
|
|
512
|
+
: [value];
|
|
513
|
+
return values.flatMap((part) => {
|
|
514
|
+
let matched = part;
|
|
515
|
+
if (badge.match) {
|
|
516
|
+
const m = part.match(badge.match);
|
|
517
|
+
if (!m)
|
|
518
|
+
return [];
|
|
519
|
+
matched = m[1] ?? m[0];
|
|
520
|
+
}
|
|
521
|
+
const { url: template, label } = badge;
|
|
522
|
+
const url = safeUrl(typeof template === "function"
|
|
523
|
+
? attributed(entry, () => template(matched, entry))
|
|
524
|
+
: template.replace(/\$1/g, () => matched));
|
|
525
|
+
if (!url)
|
|
526
|
+
return [];
|
|
527
|
+
return [{
|
|
528
|
+
value: matched,
|
|
529
|
+
url,
|
|
530
|
+
label: String(typeof label === "function" ? attributed(entry, () => label(matched, entry)) : label),
|
|
531
|
+
}];
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const PRESETS_BY_FIELD = Object.fromEntries(Object.values(badgePresets).map(preset => [preset.field, preset]));
|
|
535
|
+
/**
|
|
536
|
+
* The first title-link field that yields a URL. Fields with a preset use it,
|
|
537
|
+
* so a DOI title link and a DOI badge normalise the same way; other fields use
|
|
538
|
+
* a configured badge for that field, or must hold a URL themselves.
|
|
539
|
+
*/
|
|
540
|
+
function resolveTitleLink(entry, fields, badges) {
|
|
541
|
+
for (const field of fields) {
|
|
542
|
+
const name = field.toLowerCase();
|
|
543
|
+
const value = fieldValue(entry, field);
|
|
544
|
+
if (value === undefined)
|
|
545
|
+
continue;
|
|
546
|
+
const template = PRESETS_BY_FIELD[name] ?? badges.find(badge => badge.field.toLowerCase() === name);
|
|
547
|
+
const link = template ? applyBadge(entry, template)[0] : { value, url: safeUrl(value) };
|
|
548
|
+
if (link?.url)
|
|
549
|
+
return { kind: "title", field, value: link.value, url: link.url, entry };
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
function linkedFields(links) {
|
|
554
|
+
const fields = links.badges.map(badge => badge.field);
|
|
555
|
+
if (links.title)
|
|
556
|
+
fields.push(links.title.field);
|
|
557
|
+
return fields;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The CSL data a style gets to see: without the given (linked) fields, in either
|
|
561
|
+
* spelling (`doi`/`DOI`), and with a DOI it may print reduced to the bare DOI
|
|
562
|
+
* CSL expects. Exports write `doi:10…` or `https://doi.org/10…`, which a style
|
|
563
|
+
* would turn into `https://doi.org/doi:10…`.
|
|
564
|
+
*/
|
|
565
|
+
function displayCsl(csl, withheld) {
|
|
566
|
+
const copy = { ...csl };
|
|
567
|
+
for (const field of withheld) {
|
|
568
|
+
delete copy[field];
|
|
569
|
+
delete copy[field.toLowerCase()];
|
|
570
|
+
delete copy[field.toUpperCase()];
|
|
571
|
+
}
|
|
572
|
+
if (typeof copy.DOI === "string") {
|
|
573
|
+
const bare = copy.DOI.trim().match(badgePresets.doi.match)?.[1];
|
|
574
|
+
if (bare)
|
|
575
|
+
copy.DOI = bare;
|
|
576
|
+
}
|
|
577
|
+
return copy;
|
|
578
|
+
}
|
|
579
|
+
/** The fallback title link and the badges; the title link itself is added by citeproc's wrapper. */
|
|
580
|
+
function decorate(html, titleLinked, links, options) {
|
|
581
|
+
let out = html;
|
|
582
|
+
// Never lose a link: a style that doesn't print the title gets its URL.
|
|
583
|
+
if (links.title && !titleLinked)
|
|
584
|
+
out += ` ${linkHtml(links.title, escapeHtml(links.title.url), options)}`;
|
|
585
|
+
if (links.badges.length && options.appendBadges !== false) {
|
|
586
|
+
const badges = links.badges.map(link => linkHtml(link, escapeHtml(link.label), options));
|
|
587
|
+
out += ` <span class="${escapeAttr(options.badgeListClassName ?? "bib-links")}">${badges.join(" ")}</span>`;
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Every `<a>` the library writes. `linkAttributes` may add attributes, add a
|
|
593
|
+
* class, or replace the URL; a replaced URL is checked like any other, and an
|
|
594
|
+
* unsafe one leaves the text unlinked.
|
|
595
|
+
*/
|
|
596
|
+
function linkHtml(link, inner, options) {
|
|
597
|
+
const extra = options.linkAttributes ? attributed(link.entry, () => options.linkAttributes(link)) : {};
|
|
598
|
+
const { class: extraClass, href, ...rest } = extra;
|
|
599
|
+
const url = typeof href === "string" ? safeUrl(href) : link.url;
|
|
600
|
+
if (!url)
|
|
601
|
+
return inner;
|
|
602
|
+
const className = [link.className, typeof extraClass === "string" ? extraClass : ""].filter(Boolean).join(" ");
|
|
603
|
+
return `<a${renderAttributes({ ...(className ? { class: className } : {}), href: url, ...rest })}>${inner}</a>`;
|
|
604
|
+
}
|
|
605
|
+
/** Visible text for comparing a rendered variable with a title: no tags, plain quotes, any case. */
|
|
606
|
+
function plainText(html) {
|
|
607
|
+
return html
|
|
608
|
+
.replace(/<[^>]*>/g, "")
|
|
609
|
+
.replace(/&(?:amp|#38|#x26);/gi, "&")
|
|
610
|
+
.replace(/[\u2018\u2019]|&(?:#39|#x27|apos|rsquo|lsquo);/gi, "'")
|
|
611
|
+
.replace(/[\u201c\u201d\u201e]|&(?:quot|#34|#x22|ldquo|rdquo);/gi, '"')
|
|
612
|
+
.replace(/\s+/g, " ")
|
|
613
|
+
.trim()
|
|
614
|
+
.toLowerCase();
|
|
615
|
+
}
|
|
616
|
+
/** Run a consumer function, naming the entry if it throws. */
|
|
617
|
+
function attributed(entry, fn) {
|
|
618
|
+
try {
|
|
619
|
+
return fn();
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
623
|
+
if (message.startsWith(`entry ${entry.key}: `))
|
|
624
|
+
throw error;
|
|
625
|
+
throw new Error(`entry ${entry.key}: ${message}`, { cause: error });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
356
629
|
// Internal helpers
|
|
357
630
|
// ---------------------------------------------------------------------------
|
|
358
631
|
function readFileIfExists(input) {
|
|
@@ -390,42 +663,38 @@ function unwrapCslEntry(entryHtml) {
|
|
|
390
663
|
return null;
|
|
391
664
|
return (match[2] ?? "").trim();
|
|
392
665
|
}
|
|
393
|
-
|
|
666
|
+
/** `http(s)`, `mailto` and relative URLs; anything with another scheme is dropped. */
|
|
667
|
+
function safeUrl(url) {
|
|
394
668
|
const trimmed = url.trim();
|
|
395
|
-
if (
|
|
396
|
-
return
|
|
397
|
-
if (/^
|
|
669
|
+
if (/^(?:https?:\/\/|mailto:)/i.test(trimmed))
|
|
670
|
+
return trimmed;
|
|
671
|
+
if (/^(?:\/|\.\.?\/|#|\?)/.test(trimmed))
|
|
398
672
|
return trimmed;
|
|
399
|
-
}
|
|
400
673
|
return null;
|
|
401
674
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
break;
|
|
418
|
-
case "'":
|
|
419
|
-
pattern += "(?:'|')";
|
|
420
|
-
break;
|
|
421
|
-
default:
|
|
422
|
-
pattern += escapeRegex(ch);
|
|
423
|
-
}
|
|
675
|
+
/** `[year, month, day]` from CSL `issued`; missing parts are 0. */
|
|
676
|
+
function issuedParts(entry) {
|
|
677
|
+
const parts = entry.csl.issued?.["date-parts"]?.[0];
|
|
678
|
+
if (!Array.isArray(parts))
|
|
679
|
+
return [entry.year ?? 0, 0, 0];
|
|
680
|
+
return [0, 1, 2].map(index => Number(parts[index]) || 0);
|
|
681
|
+
}
|
|
682
|
+
function compareKeys(a, b) {
|
|
683
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
684
|
+
const x = a[i] ?? 0;
|
|
685
|
+
const y = b[i] ?? 0;
|
|
686
|
+
if (x < y)
|
|
687
|
+
return -1;
|
|
688
|
+
if (x > y)
|
|
689
|
+
return 1;
|
|
424
690
|
}
|
|
425
|
-
return
|
|
691
|
+
return 0;
|
|
426
692
|
}
|
|
427
|
-
|
|
428
|
-
|
|
693
|
+
/** Add a class to the attributes, after any the caller gave. */
|
|
694
|
+
function withClass(attrs, className) {
|
|
695
|
+
const { class: extra, ...rest } = attrs;
|
|
696
|
+
const classes = [typeof extra === "string" ? extra : "", className].filter(Boolean).join(" ");
|
|
697
|
+
return { ...rest, class: classes };
|
|
429
698
|
}
|
|
430
699
|
function escapeAttr(s) {
|
|
431
700
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
@@ -443,7 +712,7 @@ function renderAttributes(attrs) {
|
|
|
443
712
|
for (const [k, v] of Object.entries(attrs)) {
|
|
444
713
|
if (v === true)
|
|
445
714
|
parts.push(k);
|
|
446
|
-
else if (v !== false)
|
|
715
|
+
else if (v !== false && v !== undefined)
|
|
447
716
|
parts.push(`${k}="${escapeAttr(String(v))}"`);
|
|
448
717
|
}
|
|
449
718
|
return parts.length ? " " + parts.join(" ") : "";
|