@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/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
- titleLink: options.titleLink,
21
- badges: options.badges,
22
- linkifyUrls: options.linkifyUrls,
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 rawMap = new Map();
101
+ const duplicates = new Set();
30
102
  for (const entry of rawEntries) {
31
- rawMap.set(entry.label, entry.properties);
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 = rawMap.get(key) ?? {};
123
+ const raw = this.bibtexEntries.get(key)?.properties ?? {};
45
124
  const custom = {};
46
125
  for (const f of this.customFieldNames) {
47
- if (raw[f] != null)
48
- custom[f] = String(raw[f]);
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) or a custom field name
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 va = by === "year" ? (a.year ?? 0) : (a.custom[by] ?? "");
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 rendered = this.renderCslEntries([entry]);
96
- const raw = rendered[0]?.[1] ?? "";
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
- * Decorate, linkify and restore math for one rendered entry. Shared by
102
- * `formatEntry` and `formatHtml` so both honour the same options.
103
- *
104
- * Linkification runs before math restoration: rendered MathML carries an
105
- * xmlns URL that must not be turned into a link. Math is restored per entry
106
- * so a failing formula can be reported with its citation key.
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
- buildEntryHtml(entry, innerRaw, merged) {
109
- let inner = this.decorateEntryHtml(entry, innerRaw, merged);
110
- if (merged.linkifyUrls !== false)
111
- inner = linkifyBareUrls(inner);
112
- return this.restoreMath(inner, merged, entry);
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
- return {
117
- ...options,
118
- titleLink: options.titleLink ?? this.formatDefaults.titleLink,
119
- badges: options.badges ?? this.formatDefaults.badges,
120
- linkifyUrls: options.linkifyUrls ?? this.formatDefaults.linkifyUrls,
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
- * Restore protected math, naming the entry when a renderer rejects a formula.
125
- * Without the citation key, `renderMath` failures are near-impossible to
126
- * trace back to a line in the .bib file.
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
- restoreMath(html, options, entry) {
129
- if (!this.math)
130
- return html;
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
- return this.math.restore(html, options.renderMath);
285
+ rendered = this.renderCslEntries(csl, options.lang ?? "en-US");
133
286
  }
134
- catch (error) {
135
- const message = error instanceof Error ? error.message : String(error);
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
- * Format a list of entries as a complete HTML bibliography.
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
- formatHtml(entries, options = {}) {
143
- if (entries.length === 0)
144
- return "";
145
- const merged = this.mergeOptions(options);
146
- const tag = merged.list ?? "ol";
147
- const attrs = merged.listAttributes ?? (tag === "ol" ? { reversed: true } : {});
148
- const attrStr = renderAttributes(attrs);
149
- // Render all entries in one citeproc run so style-dependent numbering/state
150
- // (e.g. vancouver left-margin labels) remains correct.
151
- const rendered = this.renderCslEntries(entries);
152
- const renderedMap = new Map(rendered.map(([id, html]) => [id, html]));
153
- const itemTag = tag === "div" ? "div" : "li";
154
- const items = entries.map((entry, index) => {
155
- const id = String(entry.csl.id ?? entry.key);
156
- const raw = renderedMap.get(id)
157
- ?? renderedMap.get(entry.key)
158
- ?? rendered[index]?.[1]
159
- ?? "";
160
- const innerRaw = unwrapCslEntry(raw) ?? raw.trim();
161
- const inner = this.buildEntryHtml(entry, innerRaw, merged);
162
- return `<${itemTag} data-csl-entry-id="${escapeAttr(entry.key)}" class="csl-entry">${inner}</${itemTag}>`;
163
- });
164
- return `<${tag}${attrStr} class="csl-bib-body">\n${items.join("\n")}\n</${tag}>`;
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
- renderCslEntries(entries) {
188
- const cite = new Cite(entries.map((entry) => entry.csl));
189
- const out = cite.format("bibliography", {
190
- format: "html",
191
- template: this.templateName,
192
- lang: "en-US",
193
- nosort: true,
194
- asEntryArray: true,
195
- });
196
- if (!Array.isArray(out))
197
- return [];
198
- return out
199
- .filter((item) => Array.isArray(item) && item.length >= 2)
200
- .map(([id, html]) => [String(id), String(html)]);
201
- }
202
- decorateEntryHtml(entry, html, options) {
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
- return null;
239
- }
240
- linkTitle(html, title, url) {
241
- const pattern = buildHtmlTextPattern(title);
242
- if (!pattern)
243
- return html;
244
- const regex = new RegExp(pattern);
245
- const tokens = html.split(/(<[^>]*>)/g);
246
- let insideAnchor = false;
247
- let insideScript = false;
248
- let insideStyle = false;
249
- let linked = false;
250
- const output = [];
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
- return output.join("");
280
- }
281
- renderBadges(entry, badges) {
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
- function sanitizeUrl(url) {
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 (!trimmed)
396
- return null;
397
- if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) {
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
- function buildHtmlTextPattern(text) {
403
- let pattern = "";
404
- for (const ch of text) {
405
- switch (ch) {
406
- case "&":
407
- pattern += "(?:&amp;|&#38;)";
408
- break;
409
- case "<":
410
- pattern += "&lt;";
411
- break;
412
- case ">":
413
- pattern += "&gt;";
414
- break;
415
- case '"':
416
- pattern += "(?:&quot;|&#34;)";
417
- break;
418
- case "'":
419
- pattern += "(?:&#39;|&apos;)";
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 pattern;
691
+ return 0;
426
692
  }
427
- function escapeRegex(text) {
428
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
@@ -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(" ") : "";