@behackl/citation-js-extras 0.2.1 → 0.3.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
  // ---------------------------------------------------------------------------
@@ -13,23 +83,32 @@ export class Bibliography {
13
83
  math;
14
84
  /** Formatting defaults from the constructor; each call may override them. */
15
85
  formatDefaults;
86
+ rendering;
16
87
  constructor(options) {
17
88
  const bibData = maybeReadFile(options.data);
18
89
  this.customFieldNames = options.customFields ?? [];
19
- this.formatDefaults = {
20
- titleLink: options.titleLink,
21
- badges: options.badges,
22
- linkifyUrls: options.linkifyUrls,
23
- };
90
+ this.formatDefaults = {};
91
+ for (const key of FORMAT_DEFAULT_KEYS) {
92
+ if (options[key] !== undefined)
93
+ this.formatDefaults[key] = options[key];
94
+ }
24
95
  // Register CSL style
25
96
  this.templateName = this.registerStyle(options.cslStyle);
26
97
  // Two-pass parse: raw (preserves all fields) + CSL (for formatting)
27
98
  const { plugins } = Cite;
28
99
  const rawEntries = plugins.input.chainLink(bibData);
29
100
  const rawMap = new Map();
101
+ const duplicates = new Set();
30
102
  for (const entry of rawEntries) {
103
+ if (rawMap.has(entry.label))
104
+ duplicates.add(entry.label);
31
105
  rawMap.set(entry.label, entry.properties);
32
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(", ")}`);
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.
35
114
  this.math = options.preserveMath
@@ -44,8 +123,9 @@ export class Bibliography {
44
123
  const raw = rawMap.get(key) ?? {};
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,126 @@ 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.
107
182
  */
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);
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));
113
207
  }
208
+ // -------------------------------------------------------------------------
209
+ // Private helpers
210
+ // -------------------------------------------------------------------------
114
211
  /** Per-call options win over the defaults given to the constructor. */
115
212
  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
- };
213
+ const merged = { ...options };
214
+ for (const key of FORMAT_DEFAULT_KEYS) {
215
+ if (merged[key] === undefined)
216
+ merged[key] = this.formatDefaults[key];
217
+ }
218
+ return merged;
122
219
  }
123
220
  /**
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.
221
+ * Entry HTML without wrapper, shared by `formatEntry` and `formatHtml` so
222
+ * both honour the same options. Links are resolved first: they decide what
223
+ * the style may print and what is added afterwards.
127
224
  */
128
- restoreMath(html, options, entry) {
129
- if (!this.math)
130
- return html;
225
+ renderItems(entries, options) {
226
+ const links = entries.map(entry => this.resolveLinks(entry, options));
227
+ const csl = entries.map((entry, index) => displayCsl(entry.csl, options.printLinkedIdentifiers ? [] : linkedFields(links[index])));
228
+ const byId = new Map(entries.map((entry, index) => [String(entry.csl.id ?? entry.key), { entry, links: links[index], titleLinked: false }]));
229
+ this.rendering = { options, byId };
230
+ let rendered;
131
231
  try {
132
- return this.math.restore(html, options.renderMath);
232
+ rendered = this.renderCslEntries(csl, options.lang ?? "en-US");
133
233
  }
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 });
234
+ finally {
235
+ this.rendering = undefined;
137
236
  }
237
+ const renderedMap = new Map(rendered);
238
+ return entries.map((entry, index) => {
239
+ const id = String(entry.csl.id ?? entry.key);
240
+ const raw = renderedMap.get(id) ?? rendered[index]?.[1] ?? "";
241
+ let html = unwrapCslEntry(raw) ?? raw.trim();
242
+ return decorate(html, byId.get(id).titleLinked, links[index], options);
243
+ });
138
244
  }
139
245
  /**
140
- * Format a list of entries as a complete HTML bibliography.
246
+ * Called by citeproc for every variable it renders. Everything the library
247
+ * adds to the citation text happens here, one variable at a time, instead of
248
+ * by searching the finished HTML:
249
+ *
250
+ * - the title link, around the exact output of the title variable, so no
251
+ * quotes, markup or capitalisation the style applies can hide the title;
252
+ * - bare URLs in a variable (e.g. in a `note`) become links. Formulas are
253
+ * still placeholders here, so MathML's xmlns URL is never linked;
254
+ * - the consumer's `wrapVariable`, outermost.
141
255
  */
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}>`;
256
+ wrapVariable(params, pre, str, post) {
257
+ const state = this.rendering;
258
+ const item = state?.byId.get(String(params.itemData?.id));
259
+ if (!state || !item || params.context !== "bibliography" || params.mode !== "html" || !str) {
260
+ return pre + str + post;
261
+ }
262
+ const variable = params.variableNames?.[0] ?? "";
263
+ let html = str;
264
+ // A style may print the title in another variable's place, e.g. APA
265
+ // substitutes it for missing authors; citeproc then doesn't call it `title`.
266
+ const isTitle = variable === "title"
267
+ || (typeof item.entry.csl.title === "string" && plainText(str) === plainText(item.entry.csl.title));
268
+ if (isTitle && item.links.title && !item.titleLinked) {
269
+ html = linkHtml(item.links.title, html, state.options);
270
+ item.titleLinked = true;
271
+ }
272
+ if (state.options.linkifyUrls !== false)
273
+ html = linkifyBareUrls(html);
274
+ const wrap = state.options.wrapVariable;
275
+ if (wrap)
276
+ html = attributed(item.entry, () => wrap(html, { variable, entry: item.entry }));
277
+ return pre + html + post;
278
+ }
279
+ /** Sanitize while formulas are placeholders, then insert rendered math. */
280
+ finish(html, options) {
281
+ const safe = options.sanitize ? options.sanitize(html) : html;
282
+ return this.math ? this.math.restore(safe, options.renderMath) : safe;
283
+ }
284
+ resolveLinks(entry, options) {
285
+ const badges = options.badges ?? [];
286
+ const rendered = [];
287
+ for (const badge of badges) {
288
+ for (const link of applyBadge(entry, badge)) {
289
+ rendered.push({ kind: "badge", field: badge.field, ...link, className: badge.className, entry });
290
+ }
291
+ }
292
+ const title = typeof entry.csl.title === "string" && entry.csl.title.trim()
293
+ ? resolveTitleLink(entry, options.titleLink ?? DEFAULT_TITLE_LINK, badges)
294
+ : undefined;
295
+ return title ? { title, badges: rendered } : { badges: rendered };
165
296
  }
166
- // -------------------------------------------------------------------------
167
- // Private helpers
168
- // -------------------------------------------------------------------------
169
297
  registerStyle(cslStyle) {
170
298
  if (!cslStyle)
171
299
  return "apa";
@@ -184,127 +312,41 @@ export class Bibliography {
184
312
  }
185
313
  return name;
186
314
  }
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
- }
315
+ /**
316
+ * Render entries with citeproc, as `cite.format("bibliography")` does, but
317
+ * through an engine that has our variable wrapper: citeproc only accepts it
318
+ * when the engine is created. Styles and locales come from citation-js's
319
+ * registries, and the data is prepared the same way.
320
+ */
321
+ renderCslEntries(csl, lang) {
322
+ const data = Cite.util.downgradeCsl(csl);
323
+ const key = `${this.templateName}|${lang}`;
324
+ let engine = engines.get(key);
325
+ // citeproc is not re-entrant: a consumer function that formats with the
326
+ // same style while this engine renders gets an engine of its own.
327
+ if (!engine || busy.has(engine)) {
328
+ engine = createEngine(this.templateName, lang);
329
+ if (!engines.has(key))
330
+ engines.set(key, engine);
237
331
  }
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);
332
+ const previous = current;
333
+ current = {
334
+ items: new Map(data.map(item => [String(item.id), item])),
335
+ wrap: (params, pre, str, post) => this.wrapVariable(params, pre, str, post),
336
+ };
337
+ busy.add(engine);
338
+ try {
339
+ engine.updateItems([]);
340
+ const ids = engine.updateItems(data.map(item => String(item.id)), true);
341
+ const bibliography = engine.makeBibliography();
342
+ if (!bibliography)
343
+ return [];
344
+ return bibliography[1].map((html, index) => [String(ids[index]), html]);
278
345
  }
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>`);
346
+ finally {
347
+ busy.delete(engine);
348
+ current = previous;
304
349
  }
305
- if (parts.length === 0)
306
- return "";
307
- return `<span class="bib-links">${parts.join(" ")}</span>`;
308
350
  }
309
351
  }
310
352
  // ---------------------------------------------------------------------------
@@ -353,6 +395,151 @@ export function linkifyBareUrls(html) {
353
395
  return output.join("");
354
396
  }
355
397
  // ---------------------------------------------------------------------------
398
+ // Fields and links
399
+ // ---------------------------------------------------------------------------
400
+ /**
401
+ * The value of a BibTeX field, and the only place fields are read. Names are
402
+ * case-insensitive. A field named like an eprint archive falls back to
403
+ * `eprint` when `eprinttype`/`archivePrefix` names that archive (biblatex and
404
+ * arXiv exports). Last, the CSL variable, which covers values citation-js
405
+ * derives (e.g. `URL` from `howpublished = {\url{…}}`).
406
+ */
407
+ function fieldValue(entry, field) {
408
+ const name = field.toLowerCase();
409
+ let value = entry.raw[name] ?? entry.raw[field];
410
+ if (value == null) {
411
+ const archive = entry.raw.eprinttype ?? entry.raw.archiveprefix;
412
+ if (archive != null && String(archive).trim().toLowerCase() === name)
413
+ value = entry.raw.eprint;
414
+ }
415
+ value ??= entry.csl[field.toUpperCase()] ?? entry.csl[name];
416
+ const text = value == null ? "" : String(value).trim();
417
+ return text || undefined;
418
+ }
419
+ /** The links a badge yields for an entry: none, one, or one per `split` value. */
420
+ function applyBadge(entry, badge) {
421
+ const value = fieldValue(entry, badge.field);
422
+ if (value === undefined)
423
+ return [];
424
+ const values = badge.split
425
+ ? value.split(badge.split).map(part => part.trim()).filter(Boolean)
426
+ : [value];
427
+ return values.flatMap((part) => {
428
+ let matched = part;
429
+ if (badge.match) {
430
+ const m = part.match(badge.match);
431
+ if (!m)
432
+ return [];
433
+ matched = m[1] ?? m[0];
434
+ }
435
+ const { url: template, label } = badge;
436
+ const url = safeUrl(typeof template === "function"
437
+ ? attributed(entry, () => template(matched, entry))
438
+ : template.replace(/\$1/g, () => matched));
439
+ if (!url)
440
+ return [];
441
+ return [{
442
+ value: matched,
443
+ url,
444
+ label: String(typeof label === "function" ? attributed(entry, () => label(matched, entry)) : label),
445
+ }];
446
+ });
447
+ }
448
+ const PRESETS_BY_FIELD = Object.fromEntries(Object.values(badgePresets).map(preset => [preset.field, preset]));
449
+ /**
450
+ * The first title-link field that yields a URL. Fields with a preset use it,
451
+ * so a DOI title link and a DOI badge normalise the same way; other fields use
452
+ * a configured badge for that field, or must hold a URL themselves.
453
+ */
454
+ function resolveTitleLink(entry, fields, badges) {
455
+ for (const field of fields) {
456
+ const name = field.toLowerCase();
457
+ const value = fieldValue(entry, field);
458
+ if (value === undefined)
459
+ continue;
460
+ const template = PRESETS_BY_FIELD[name] ?? badges.find(badge => badge.field.toLowerCase() === name);
461
+ const link = template ? applyBadge(entry, template)[0] : { value, url: safeUrl(value) };
462
+ if (link?.url)
463
+ return { kind: "title", field, value: link.value, url: link.url, entry };
464
+ }
465
+ return undefined;
466
+ }
467
+ function linkedFields(links) {
468
+ const fields = links.badges.map(badge => badge.field);
469
+ if (links.title)
470
+ fields.push(links.title.field);
471
+ return fields;
472
+ }
473
+ /**
474
+ * The CSL data a style gets to see: without the given (linked) fields, in either
475
+ * spelling (`doi`/`DOI`), and with a DOI it may print reduced to the bare DOI
476
+ * CSL expects. Exports write `doi:10…` or `https://doi.org/10…`, which a style
477
+ * would turn into `https://doi.org/doi:10…`.
478
+ */
479
+ function displayCsl(csl, withheld) {
480
+ const copy = { ...csl };
481
+ for (const field of withheld) {
482
+ delete copy[field];
483
+ delete copy[field.toLowerCase()];
484
+ delete copy[field.toUpperCase()];
485
+ }
486
+ if (typeof copy.DOI === "string") {
487
+ const bare = copy.DOI.trim().match(badgePresets.doi.match)?.[1];
488
+ if (bare)
489
+ copy.DOI = bare;
490
+ }
491
+ return copy;
492
+ }
493
+ /** The fallback title link and the badges; the title link itself is added by citeproc's wrapper. */
494
+ function decorate(html, titleLinked, links, options) {
495
+ let out = html;
496
+ // Never lose a link: a style that doesn't print the title gets its URL.
497
+ if (links.title && !titleLinked)
498
+ out += ` ${linkHtml(links.title, escapeHtml(links.title.url), options)}`;
499
+ if (links.badges.length && options.appendBadges !== false) {
500
+ const badges = links.badges.map(link => linkHtml(link, escapeHtml(link.label ?? ""), options));
501
+ out += ` <span class="${escapeAttr(options.badgeListClassName ?? "bib-links")}">${badges.join(" ")}</span>`;
502
+ }
503
+ return out;
504
+ }
505
+ /**
506
+ * Every `<a>` the library writes. `linkAttributes` may add attributes, add a
507
+ * class, or replace the URL; a replaced URL is checked like any other, and an
508
+ * unsafe one leaves the text unlinked.
509
+ */
510
+ function linkHtml(link, inner, options) {
511
+ const extra = options.linkAttributes ? attributed(link.entry, () => options.linkAttributes(link)) : {};
512
+ const { class: extraClass, href, ...rest } = extra;
513
+ const url = typeof href === "string" ? safeUrl(href) : link.url;
514
+ if (!url)
515
+ return inner;
516
+ const className = [link.className, typeof extraClass === "string" ? extraClass : ""].filter(Boolean).join(" ");
517
+ return `<a${renderAttributes({ ...(className ? { class: className } : {}), href: url, ...rest })}>${inner}</a>`;
518
+ }
519
+ /** Visible text for comparing a rendered variable with a title: no tags, plain quotes, any case. */
520
+ function plainText(html) {
521
+ return html
522
+ .replace(/<[^>]*>/g, "")
523
+ .replace(/&(?:amp|#38|#x26);/gi, "&")
524
+ .replace(/[\u2018\u2019]|&(?:#39|#x27|apos|rsquo|lsquo);/gi, "'")
525
+ .replace(/[\u201c\u201d\u201e]|&(?:quot|#34|#x22|ldquo|rdquo);/gi, '"')
526
+ .replace(/\s+/g, " ")
527
+ .trim()
528
+ .toLowerCase();
529
+ }
530
+ /** Run a consumer function, naming the entry if it throws. */
531
+ function attributed(entry, fn) {
532
+ try {
533
+ return fn();
534
+ }
535
+ catch (error) {
536
+ const message = error instanceof Error ? error.message : String(error);
537
+ if (message.startsWith(`entry ${entry.key}: `))
538
+ throw error;
539
+ throw new Error(`entry ${entry.key}: ${message}`, { cause: error });
540
+ }
541
+ }
542
+ // ---------------------------------------------------------------------------
356
543
  // Internal helpers
357
544
  // ---------------------------------------------------------------------------
358
545
  function readFileIfExists(input) {
@@ -390,42 +577,38 @@ function unwrapCslEntry(entryHtml) {
390
577
  return null;
391
578
  return (match[2] ?? "").trim();
392
579
  }
393
- function sanitizeUrl(url) {
580
+ /** `http(s)`, `mailto` and relative URLs; anything with another scheme is dropped. */
581
+ function safeUrl(url) {
394
582
  const trimmed = url.trim();
395
- if (!trimmed)
396
- return null;
397
- if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) {
583
+ if (/^(?:https?:\/\/|mailto:)/i.test(trimmed))
584
+ return trimmed;
585
+ if (/^(?:\/|\.\.?\/|#|\?)/.test(trimmed))
398
586
  return trimmed;
399
- }
400
587
  return null;
401
588
  }
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
- }
589
+ /** `[year, month, day]` from CSL `issued`; missing parts are 0. */
590
+ function issuedParts(entry) {
591
+ const parts = entry.csl.issued?.["date-parts"]?.[0];
592
+ if (!Array.isArray(parts))
593
+ return [entry.year ?? 0, 0, 0];
594
+ return [0, 1, 2].map(index => Number(parts[index]) || 0);
595
+ }
596
+ function compareKeys(a, b) {
597
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
598
+ const x = a[i] ?? 0;
599
+ const y = b[i] ?? 0;
600
+ if (x < y)
601
+ return -1;
602
+ if (x > y)
603
+ return 1;
424
604
  }
425
- return pattern;
605
+ return 0;
426
606
  }
427
- function escapeRegex(text) {
428
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
607
+ /** Add a class to the attributes, after any the caller gave. */
608
+ function withClass(attrs, className) {
609
+ const { class: extra, ...rest } = attrs;
610
+ const classes = [typeof extra === "string" ? extra : "", className].filter(Boolean).join(" ");
611
+ return { ...rest, class: classes };
429
612
  }
430
613
  function escapeAttr(s) {
431
614
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");