@behackl/citation-js-extras 0.2.0 → 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
  // ---------------------------------------------------------------------------
@@ -11,18 +81,34 @@ export class Bibliography {
11
81
  entries;
12
82
  customFieldNames;
13
83
  math;
84
+ /** Formatting defaults from the constructor; each call may override them. */
85
+ formatDefaults;
86
+ rendering;
14
87
  constructor(options) {
15
88
  const bibData = maybeReadFile(options.data);
16
89
  this.customFieldNames = options.customFields ?? [];
90
+ this.formatDefaults = {};
91
+ for (const key of FORMAT_DEFAULT_KEYS) {
92
+ if (options[key] !== undefined)
93
+ this.formatDefaults[key] = options[key];
94
+ }
17
95
  // Register CSL style
18
96
  this.templateName = this.registerStyle(options.cslStyle);
19
97
  // Two-pass parse: raw (preserves all fields) + CSL (for formatting)
20
98
  const { plugins } = Cite;
21
99
  const rawEntries = plugins.input.chainLink(bibData);
22
100
  const rawMap = new Map();
101
+ const duplicates = new Set();
23
102
  for (const entry of rawEntries) {
103
+ if (rawMap.has(entry.label))
104
+ duplicates.add(entry.label);
24
105
  rawMap.set(entry.label, entry.properties);
25
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
+ }
26
112
  // Protect resolved raw fields, after BibTeX strings/concatenations are parsed
27
113
  // but before the lossy TeX-to-CSL conversion. Keep the original raw map intact.
28
114
  this.math = options.preserveMath
@@ -37,8 +123,9 @@ export class Bibliography {
37
123
  const raw = rawMap.get(key) ?? {};
38
124
  const custom = {};
39
125
  for (const f of this.customFieldNames) {
40
- if (raw[f] != null)
41
- custom[f] = String(raw[f]);
126
+ const value = raw[f.toLowerCase()] ?? raw[f];
127
+ if (value != null)
128
+ custom[f] = String(value);
42
129
  }
43
130
  return {
44
131
  csl,
@@ -62,17 +149,19 @@ export class Bibliography {
62
149
  return this.entries.filter((e) => Object.entries(criteria).every(([k, v]) => e.custom[k] === v));
63
150
  }
64
151
  /**
65
- * Return a sorted **copy** of the given entries.
152
+ * Return a sorted **copy** of the given entries. Ties keep their input order.
66
153
  *
67
154
  * @param entries - entries to sort (not mutated)
68
- * @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
69
157
  * @param order - `'desc'` (default) or `'asc'`
70
158
  */
71
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] ?? ""];
72
163
  return [...entries].sort((a, b) => {
73
- const va = by === "year" ? (a.year ?? 0) : (a.custom[by] ?? "");
74
- const vb = by === "year" ? (b.year ?? 0) : (b.custom[by] ?? "");
75
- const cmp = va < vb ? -1 : va > vb ? 1 : 0;
164
+ const cmp = compareKeys(keyOf(a), keyOf(b));
76
165
  return order === "desc" ? -cmp : cmp;
77
166
  });
78
167
  }
@@ -81,14 +170,12 @@ export class Bibliography {
81
170
  // -------------------------------------------------------------------------
82
171
  /**
83
172
  * Format a single entry as an HTML string (no wrapper element).
84
- * Applies title linking and badge injection.
173
+ * Applies title linking, badge injection and URL linkification.
85
174
  */
86
175
  formatEntry(entry, options = {}) {
87
- const rendered = this.renderCslEntries([entry]);
88
- const raw = rendered[0]?.[1] ?? "";
89
- const innerHtml = unwrapCslEntry(raw) ?? raw.trim();
90
- const decorated = this.decorateEntryHtml(entry, innerHtml, options);
91
- return this.math ? this.math.restore(decorated, options.renderMath) : decorated;
176
+ const merged = this.mergeOptions(options);
177
+ const [html = ""] = this.renderItems([entry], merged);
178
+ return this.finish(html, merged);
92
179
  }
93
180
  /**
94
181
  * Format a list of entries as a complete HTML bibliography.
@@ -96,33 +183,117 @@ export class Bibliography {
96
183
  formatHtml(entries, options = {}) {
97
184
  if (entries.length === 0)
98
185
  return "";
99
- const tag = options.list ?? "ol";
100
- const attrs = options.listAttributes ?? (tag === "ol" ? { reversed: true } : {});
101
- const attrStr = renderAttributes(attrs);
102
- // Render all entries in one citeproc run so style-dependent numbering/state
103
- // (e.g. vancouver left-margin labels) remains correct.
104
- const rendered = this.renderCslEntries(entries);
105
- const renderedMap = new Map(rendered.map(([id, html]) => [id, html]));
186
+ const merged = this.mergeOptions(options);
187
+ const tag = merged.list ?? "ol";
188
+ const listAttributes = merged.listAttributes ?? (tag === "ol" ? { reversed: true } : {});
106
189
  const itemTag = tag === "div" ? "div" : "li";
107
- const items = entries.map((entry, index) => {
108
- const id = String(entry.csl.id ?? entry.key);
109
- const raw = renderedMap.get(id)
110
- ?? renderedMap.get(entry.key)
111
- ?? rendered[index]?.[1]
112
- ?? "";
113
- const innerRaw = unwrapCslEntry(raw) ?? raw.trim();
114
- const inner = this.decorateEntryHtml(entry, innerRaw, options);
115
- return `<${itemTag} data-csl-entry-id="${escapeAttr(entry.key)}" class="csl-entry">${inner}</${itemTag}>`;
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}>`;
116
196
  });
117
- let html = `<${tag}${attrStr} class="csl-bib-body">\n${items.join("\n")}\n</${tag}>`;
118
- if (options.linkifyUrls !== false) {
119
- html = linkifyBareUrls(html);
120
- }
121
- return this.math ? this.math.restore(html, options.renderMath) : html;
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));
122
207
  }
123
208
  // -------------------------------------------------------------------------
124
209
  // Private helpers
125
210
  // -------------------------------------------------------------------------
211
+ /** Per-call options win over the defaults given to the constructor. */
212
+ mergeOptions(options) {
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;
219
+ }
220
+ /**
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.
224
+ */
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;
231
+ try {
232
+ rendered = this.renderCslEntries(csl, options.lang ?? "en-US");
233
+ }
234
+ finally {
235
+ this.rendering = undefined;
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
+ });
244
+ }
245
+ /**
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.
255
+ */
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 };
296
+ }
126
297
  registerStyle(cslStyle) {
127
298
  if (!cslStyle)
128
299
  return "apa";
@@ -141,127 +312,41 @@ export class Bibliography {
141
312
  }
142
313
  return name;
143
314
  }
144
- renderCslEntries(entries) {
145
- const cite = new Cite(entries.map((entry) => entry.csl));
146
- const out = cite.format("bibliography", {
147
- format: "html",
148
- template: this.templateName,
149
- lang: "en-US",
150
- nosort: true,
151
- asEntryArray: true,
152
- });
153
- if (!Array.isArray(out))
154
- return [];
155
- return out
156
- .filter((item) => Array.isArray(item) && item.length >= 2)
157
- .map(([id, html]) => [String(id), String(html)]);
158
- }
159
- decorateEntryHtml(entry, html, options) {
160
- let out = html;
161
- // Link the title text
162
- const titleUrl = this.resolveTitleLink(entry, options.titleLink);
163
- const title = entry.csl.title;
164
- if (titleUrl && typeof title === "string" && title.trim()) {
165
- out = this.linkTitle(out, title, titleUrl);
166
- }
167
- // Append badges
168
- const badges = options.badges ?? [];
169
- const badgeHtml = this.renderBadges(entry, badges);
170
- if (badgeHtml) {
171
- out += ` ${badgeHtml}`;
172
- }
173
- return out;
174
- }
175
- resolveTitleLink(entry, fields) {
176
- const order = fields ?? ["url", "doi", "arxiv"];
177
- for (const field of order) {
178
- const value = entry.raw[field] ?? entry.csl[field.toUpperCase()] ?? entry.csl[field];
179
- if (!value)
180
- continue;
181
- const raw = String(value).trim();
182
- if (!raw)
183
- continue;
184
- if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw)) {
185
- return sanitizeUrl(raw);
186
- }
187
- if (field === "doi") {
188
- const doi = raw.replace(/^doi:\s*/i, "");
189
- return sanitizeUrl(`https://doi.org/${doi}`);
190
- }
191
- if (field === "arxiv") {
192
- return sanitizeUrl(`https://arxiv.org/abs/${raw.replace(/v\d+$/, "")}`);
193
- }
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);
194
331
  }
195
- return null;
196
- }
197
- linkTitle(html, title, url) {
198
- const pattern = buildHtmlTextPattern(title);
199
- if (!pattern)
200
- return html;
201
- const regex = new RegExp(pattern);
202
- const tokens = html.split(/(<[^>]*>)/g);
203
- let insideAnchor = false;
204
- let insideScript = false;
205
- let insideStyle = false;
206
- let linked = false;
207
- const output = [];
208
- for (const token of tokens) {
209
- if (token.startsWith("<")) {
210
- const lower = token.toLowerCase();
211
- if (/^<a\b/.test(lower))
212
- insideAnchor = true;
213
- if (/^<\/a\b/.test(lower))
214
- insideAnchor = false;
215
- if (/^<script\b/.test(lower))
216
- insideScript = true;
217
- if (/^<\/script\b/.test(lower))
218
- insideScript = false;
219
- if (/^<style\b/.test(lower))
220
- insideStyle = true;
221
- if (/^<\/style\b/.test(lower))
222
- insideStyle = false;
223
- output.push(token);
224
- continue;
225
- }
226
- if (linked || insideAnchor || insideScript || insideStyle) {
227
- output.push(token);
228
- continue;
229
- }
230
- const replaced = token.replace(regex, (match) => {
231
- linked = true;
232
- return `<a href="${escapeAttr(url)}">${match}</a>`;
233
- });
234
- 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]);
235
345
  }
236
- return output.join("");
237
- }
238
- renderBadges(entry, badges) {
239
- const parts = [];
240
- for (const badge of badges) {
241
- const rawValue = entry.raw[badge.field] ?? entry.custom[badge.field];
242
- if (rawValue == null)
243
- continue;
244
- const strValue = String(rawValue);
245
- let insertValue;
246
- if (badge.match) {
247
- const m = strValue.match(badge.match);
248
- if (!m)
249
- continue;
250
- insertValue = m[1] ?? m[0];
251
- }
252
- else {
253
- insertValue = strValue;
254
- }
255
- const unsafeUrl = badge.url.replace(/\$1/g, insertValue);
256
- const safeUrl = sanitizeUrl(unsafeUrl);
257
- if (!safeUrl)
258
- continue;
259
- const cls = badge.className ? ` class="${escapeAttr(badge.className)}"` : "";
260
- parts.push(`<a${cls} href="${escapeAttr(safeUrl)}">${escapeHtml(String(badge.label))}</a>`);
346
+ finally {
347
+ busy.delete(engine);
348
+ current = previous;
261
349
  }
262
- if (parts.length === 0)
263
- return "";
264
- return `<span class="bib-links">${parts.join(" ")}</span>`;
265
350
  }
266
351
  }
267
352
  // ---------------------------------------------------------------------------
@@ -310,6 +395,151 @@ export function linkifyBareUrls(html) {
310
395
  return output.join("");
311
396
  }
312
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
+ // ---------------------------------------------------------------------------
313
543
  // Internal helpers
314
544
  // ---------------------------------------------------------------------------
315
545
  function readFileIfExists(input) {
@@ -347,42 +577,38 @@ function unwrapCslEntry(entryHtml) {
347
577
  return null;
348
578
  return (match[2] ?? "").trim();
349
579
  }
350
- function sanitizeUrl(url) {
580
+ /** `http(s)`, `mailto` and relative URLs; anything with another scheme is dropped. */
581
+ function safeUrl(url) {
351
582
  const trimmed = url.trim();
352
- if (!trimmed)
353
- return null;
354
- if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) {
583
+ if (/^(?:https?:\/\/|mailto:)/i.test(trimmed))
584
+ return trimmed;
585
+ if (/^(?:\/|\.\.?\/|#|\?)/.test(trimmed))
355
586
  return trimmed;
356
- }
357
587
  return null;
358
588
  }
359
- function buildHtmlTextPattern(text) {
360
- let pattern = "";
361
- for (const ch of text) {
362
- switch (ch) {
363
- case "&":
364
- pattern += "(?:&amp;|&#38;)";
365
- break;
366
- case "<":
367
- pattern += "&lt;";
368
- break;
369
- case ">":
370
- pattern += "&gt;";
371
- break;
372
- case '"':
373
- pattern += "(?:&quot;|&#34;)";
374
- break;
375
- case "'":
376
- pattern += "(?:&#39;|&apos;)";
377
- break;
378
- default:
379
- pattern += escapeRegex(ch);
380
- }
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;
381
604
  }
382
- return pattern;
605
+ return 0;
383
606
  }
384
- function escapeRegex(text) {
385
- 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 };
386
612
  }
387
613
  function escapeAttr(s) {
388
614
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");