@behackl/citation-js-extras 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Hackl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # @behackl/citation-js-extras
2
+
3
+ Preserve custom BibTeX fields through [citation-js](https://citation.js.org/) and render academic bibliographies with linked titles, configurable badges, and more.
4
+
5
+ ## The problem
6
+
7
+ citation-js converts BibTeX to CSL-JSON, but silently **drops all non-standard fields** during the conversion. There is no plugin hook or configuration option to preserve them. Fields like `arxiv`, `mrnumber`, `publication-status`, or project identifiers are lost.
8
+
9
+ This package solves the problem with a two-pass parsing strategy: one pass extracts the raw BibTeX fields, the other produces CSL-JSON for formatting. The results are merged so you get the best of both worlds.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @behackl/citation-js-extras citation-js
15
+ # or
16
+ pnpm add @behackl/citation-js-extras citation-js
17
+ ```
18
+
19
+ `citation-js` is a **peer dependency** — you bring your own version.
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { Bibliography } from "@behackl/citation-js-extras";
25
+
26
+ const bib = new Bibliography({
27
+ data: "./references.bib", // file path or raw BibTeX string
28
+ cslStyle: "./my-style.csl", // optional: file path, raw XML, or registered template name
29
+ customFields: ["publication-status", "arxiv", "mrnumber"],
30
+ });
31
+
32
+ // Filter and sort
33
+ const published = bib.filter({ "publication-status": "published" });
34
+ const sorted = bib.sort(published, { by: "year", order: "desc" });
35
+
36
+ // Render HTML
37
+ const html = bib.formatHtml(sorted, {
38
+ titleLink: ["url", "doi", "arxiv"],
39
+ badges: [
40
+ { field: "doi", label: "doi", url: "https://doi.org/$1", className: "badge-doi" },
41
+ {
42
+ field: "arxiv",
43
+ label: "arXiv",
44
+ url: "https://arxiv.org/abs/$1",
45
+ match: /^(.+?)(?:v\d+)?$/,
46
+ className: "badge-arxiv",
47
+ },
48
+ ],
49
+ });
50
+ ```
51
+
52
+ ## API
53
+
54
+ ### `new Bibliography(options)`
55
+
56
+ | Option | Type | Description |
57
+ |---|---|---|
58
+ | `data` | `string` | BibTeX input — a raw string or a file path. |
59
+ | `cslStyle` | `string?` | CSL style — a registered template name, raw XML, or a file path. Defaults to `'apa'`. |
60
+ | `customFields` | `string[]?` | BibTeX field names to preserve. These appear on each entry under `.custom`. |
61
+
62
+ ### `bib.entries`
63
+
64
+ All parsed entries as `BibEntry[]`:
65
+
66
+ ```ts
67
+ interface BibEntry {
68
+ csl: Record<string, any>; // CSL-JSON data (for citation-js)
69
+ key: string; // BibTeX citation key
70
+ year: number | null; // extracted from CSL `issued`
71
+ custom: Record<string, string>; // declared custom fields
72
+ raw: Record<string, any>; // all raw BibTeX properties
73
+ }
74
+ ```
75
+
76
+ ### `bib.filter(criteria)`
77
+
78
+ Filter entries by custom field values. All criteria must match (AND logic).
79
+
80
+ ```ts
81
+ bib.filter({ "publication-status": "published" });
82
+ bib.filter({ "publication-status": "published", project: "ABC-123" });
83
+ ```
84
+
85
+ ### `bib.sort(entries, options?)`
86
+
87
+ Return a sorted **copy** of the entries (the input is not mutated).
88
+
89
+ ```ts
90
+ bib.sort(entries); // by year, descending (default)
91
+ bib.sort(entries, { by: "year", order: "asc" });
92
+ ```
93
+
94
+ ### `bib.formatHtml(entries, options?)`
95
+
96
+ Render entries as a complete HTML bibliography list.
97
+
98
+ ```ts
99
+ bib.formatHtml(entries, {
100
+ titleLink: ["url", "doi", "arxiv"],
101
+ badges: [ /* ... */ ],
102
+ list: "ol", // 'ol', 'ul', or 'div' (div uses <div class="csl-entry"> children)
103
+ listAttributes: { reversed: true },
104
+ linkifyUrls: true,
105
+ });
106
+ ```
107
+
108
+ Entries are formatted in one citeproc pass, so style-dependent state (for example numeric labels in Vancouver) remains correct.
109
+
110
+ ### `bib.formatEntry(entry, options?)`
111
+
112
+ Render a single entry as an HTML string (no list wrapper). The title link targets the actual CSL title text, regardless of italics.
113
+
114
+ For citation styles that depend on multi-entry context (numbered labels, ibid behavior, etc.), prefer `formatHtml(...)`.
115
+
116
+ Title links are only created for safe URL schemes (`http`, `https`, `mailto`) or normalized DOI/arXiv links.
117
+
118
+ ### Badges
119
+
120
+ Badges are small inline links appended to each entry. They are configured declaratively:
121
+
122
+ ```ts
123
+ interface BadgeConfig {
124
+ field: string; // BibTeX field name to read
125
+ label: string; // display text (e.g. "doi", "arXiv")
126
+ url: string; // URL template — $1 is replaced by the field value
127
+ match?: RegExp; // optional: validate/transform the field value
128
+ className?: string; // CSS class(es) for the <a> element
129
+ }
130
+ ```
131
+
132
+ The `url` template uses `$1` as a placeholder for the field value:
133
+
134
+ ```ts
135
+ { field: "doi", label: "doi", url: "https://doi.org/$1" }
136
+ // doi: "10.1234/example" → href="https://doi.org/10.1234/example"
137
+ ```
138
+
139
+ When `match` is provided, the field value is tested against the regex. If it doesn't match, the badge is skipped. If it matches, `$1` in the URL is replaced by the **first capture group** (or the full match if there are no capture groups):
140
+
141
+ ```ts
142
+ // Strip version suffix from arXiv IDs:
143
+ { field: "arxiv", label: "arXiv",
144
+ url: "https://arxiv.org/abs/$1",
145
+ match: /^(.+?)(?:v\d+)?$/ }
146
+ // "2301.00001v3" → capture group "2301.00001" → href=".../2301.00001"
147
+
148
+ // Only link if the field looks like a valid identifier:
149
+ { field: "zbl", label: "zbMATH",
150
+ url: "https://zbmath.org/?q=an:$1",
151
+ match: /^(\d+\.\d+)$/ }
152
+ // "7654.12345" → match → linked
153
+ // "not-a-number" → no match → badge skipped
154
+ ```
155
+
156
+ Badge labels are HTML-escaped before rendering. Generated badge links are emitted only for `http(s)` and `mailto:` URLs; unsafe schemes are skipped.
157
+
158
+ ### `linkifyBareUrls(html)`
159
+
160
+ Standalone utility: auto-linkify bare `http(s)://` URLs in HTML text nodes that aren't already inside `<a>`, `<script>`, or `<style>` tags. Trailing punctuation is kept outside the link.
161
+
162
+ ```ts
163
+ import { linkifyBareUrls } from "@behackl/citation-js-extras";
164
+
165
+ linkifyBareUrls("See https://example.com.");
166
+ // → 'See <a href="https://example.com">https://example.com</a>.'
167
+ ```
168
+
169
+ ## Custom CSL styles
170
+
171
+ Pass a file path or raw XML to `cslStyle`. You can also pass the name of any template already registered with citation-js:
172
+
173
+ ```ts
174
+ const bib = new Bibliography({
175
+ data: bibtex,
176
+ cslStyle: "./styles/my-department.csl",
177
+ customFields: ["publication-status"],
178
+ });
179
+ ```
180
+
181
+ The style is registered with citation-js and used for all formatting calls.
182
+
183
+ Raw CSL XML styles are internally registered under deterministic content-hash names to avoid collisions between multiple `Bibliography` instances.
184
+
185
+ ## How it works
186
+
187
+ citation-js has a hardcoded list of ~106 BibTeX → CSL field mappings. Any field not in that list is silently dropped. There is no plugin API to extend this mapping.
188
+
189
+ This package works around the limitation with a **two-pass parse**:
190
+
191
+ 1. `Cite.plugins.input.chainLink(bibData)` — returns raw BibTeX entries with **all** fields preserved (but no CSL conversion).
192
+ 2. `new Cite(bibData)` — returns CSL-JSON entries (needed for formatted output via citeproc) but with custom fields stripped.
193
+
194
+ The results are merged by citation key, giving you CSL-formatted output **and** access to every custom BibTeX field.
195
+
196
+ ## License
197
+
198
+ MIT
@@ -0,0 +1,49 @@
1
+ import type { BibEntry, BibliographyOptions, FormatOptions } from "./types.js";
2
+ export type { BadgeConfig, BibEntry, BibliographyOptions, FormatOptions } from "./types.js";
3
+ export declare class Bibliography {
4
+ /** The CSL template name to use for formatting. */
5
+ readonly templateName: string;
6
+ /** All parsed entries. */
7
+ readonly entries: BibEntry[];
8
+ private readonly customFieldNames;
9
+ constructor(options: BibliographyOptions);
10
+ /**
11
+ * Return entries whose custom fields match **all** given key/value pairs.
12
+ *
13
+ * @example
14
+ * bib.filter({ 'publication-status': 'published' })
15
+ */
16
+ filter(criteria: Record<string, string>): BibEntry[];
17
+ /**
18
+ * Return a sorted **copy** of the given entries.
19
+ *
20
+ * @param entries - entries to sort (not mutated)
21
+ * @param by - `'year'` (default) or a custom field name
22
+ * @param order - `'desc'` (default) or `'asc'`
23
+ */
24
+ sort(entries: BibEntry[], { by, order }?: {
25
+ by?: string;
26
+ order?: "asc" | "desc";
27
+ }): BibEntry[];
28
+ /**
29
+ * Format a single entry as an HTML string (no wrapper element).
30
+ * Applies title linking and badge injection.
31
+ */
32
+ formatEntry(entry: BibEntry, options?: FormatOptions): string;
33
+ /**
34
+ * Format a list of entries as a complete HTML bibliography.
35
+ */
36
+ formatHtml(entries: BibEntry[], options?: FormatOptions): string;
37
+ private registerStyle;
38
+ private renderCslEntries;
39
+ private decorateEntryHtml;
40
+ private resolveTitleLink;
41
+ private linkTitle;
42
+ private renderBadges;
43
+ }
44
+ /**
45
+ * Auto-linkify bare `http(s)://` URLs in HTML that aren't already inside
46
+ * an `<a>` tag. Trailing punctuation (`.`, `,`, `;`, etc.) is kept outside
47
+ * the link.
48
+ */
49
+ export declare function linkifyBareUrls(html: string): string;
package/dist/index.js ADDED
@@ -0,0 +1,404 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import Cite from "citation-js";
3
+ // ---------------------------------------------------------------------------
4
+ // Bibliography class
5
+ // ---------------------------------------------------------------------------
6
+ export class Bibliography {
7
+ /** The CSL template name to use for formatting. */
8
+ templateName;
9
+ /** All parsed entries. */
10
+ entries;
11
+ customFieldNames;
12
+ constructor(options) {
13
+ const bibData = maybeReadFile(options.data);
14
+ this.customFieldNames = options.customFields ?? [];
15
+ // Register CSL style
16
+ this.templateName = this.registerStyle(options.cslStyle);
17
+ // Two-pass parse: raw (preserves all fields) + CSL (for formatting)
18
+ const { plugins } = Cite;
19
+ const rawEntries = plugins.input.chainLink(bibData);
20
+ const rawMap = new Map();
21
+ for (const entry of rawEntries) {
22
+ rawMap.set(entry.label, entry.properties);
23
+ }
24
+ const cite = new Cite(bibData);
25
+ this.entries = cite.data.map((csl) => {
26
+ const key = String(csl["citation-key"] || csl.id);
27
+ const raw = rawMap.get(key) ?? {};
28
+ const custom = {};
29
+ for (const f of this.customFieldNames) {
30
+ if (raw[f] != null)
31
+ custom[f] = String(raw[f]);
32
+ }
33
+ return {
34
+ csl,
35
+ key,
36
+ year: csl.issued?.["date-parts"]?.[0]?.[0] ?? null,
37
+ custom,
38
+ raw,
39
+ };
40
+ });
41
+ }
42
+ // -------------------------------------------------------------------------
43
+ // Filtering & sorting
44
+ // -------------------------------------------------------------------------
45
+ /**
46
+ * Return entries whose custom fields match **all** given key/value pairs.
47
+ *
48
+ * @example
49
+ * bib.filter({ 'publication-status': 'published' })
50
+ */
51
+ filter(criteria) {
52
+ return this.entries.filter((e) => Object.entries(criteria).every(([k, v]) => e.custom[k] === v));
53
+ }
54
+ /**
55
+ * Return a sorted **copy** of the given entries.
56
+ *
57
+ * @param entries - entries to sort (not mutated)
58
+ * @param by - `'year'` (default) or a custom field name
59
+ * @param order - `'desc'` (default) or `'asc'`
60
+ */
61
+ sort(entries, { by = "year", order = "desc" } = {}) {
62
+ return [...entries].sort((a, b) => {
63
+ const va = by === "year" ? (a.year ?? 0) : (a.custom[by] ?? "");
64
+ const vb = by === "year" ? (b.year ?? 0) : (b.custom[by] ?? "");
65
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
66
+ return order === "desc" ? -cmp : cmp;
67
+ });
68
+ }
69
+ // -------------------------------------------------------------------------
70
+ // Formatting
71
+ // -------------------------------------------------------------------------
72
+ /**
73
+ * Format a single entry as an HTML string (no wrapper element).
74
+ * Applies title linking and badge injection.
75
+ */
76
+ formatEntry(entry, options = {}) {
77
+ const rendered = this.renderCslEntries([entry]);
78
+ const raw = rendered[0]?.[1] ?? "";
79
+ const innerHtml = unwrapCslEntry(raw) ?? raw.trim();
80
+ return this.decorateEntryHtml(entry, innerHtml, options);
81
+ }
82
+ /**
83
+ * Format a list of entries as a complete HTML bibliography.
84
+ */
85
+ formatHtml(entries, options = {}) {
86
+ if (entries.length === 0)
87
+ return "";
88
+ const tag = options.list ?? "ol";
89
+ const attrs = options.listAttributes ?? (tag === "ol" ? { reversed: true } : {});
90
+ const attrStr = renderAttributes(attrs);
91
+ // Render all entries in one citeproc run so style-dependent numbering/state
92
+ // (e.g. vancouver left-margin labels) remains correct.
93
+ const rendered = this.renderCslEntries(entries);
94
+ const renderedMap = new Map(rendered.map(([id, html]) => [id, html]));
95
+ const itemTag = tag === "div" ? "div" : "li";
96
+ const items = entries.map((entry, index) => {
97
+ const id = String(entry.csl.id ?? entry.key);
98
+ const raw = renderedMap.get(id)
99
+ ?? renderedMap.get(entry.key)
100
+ ?? rendered[index]?.[1]
101
+ ?? "";
102
+ const innerRaw = unwrapCslEntry(raw) ?? raw.trim();
103
+ const inner = this.decorateEntryHtml(entry, innerRaw, options);
104
+ return `<${itemTag} data-csl-entry-id="${escapeAttr(entry.key)}" class="csl-entry">${inner}</${itemTag}>`;
105
+ });
106
+ let html = `<${tag}${attrStr} class="csl-bib-body">\n${items.join("\n")}\n</${tag}>`;
107
+ if (options.linkifyUrls !== false) {
108
+ html = linkifyBareUrls(html);
109
+ }
110
+ return html;
111
+ }
112
+ // -------------------------------------------------------------------------
113
+ // Private helpers
114
+ // -------------------------------------------------------------------------
115
+ registerStyle(cslStyle) {
116
+ if (!cslStyle)
117
+ return "apa";
118
+ const config = Cite.plugins.config.get("@csl");
119
+ const templates = config.templates;
120
+ if (templateExists(templates, cslStyle)) {
121
+ return cslStyle;
122
+ }
123
+ const xml = readFileIfExists(cslStyle) ?? (looksLikeXml(cslStyle) ? cslStyle : null);
124
+ if (!xml) {
125
+ throw new Error(`Unknown CSL style "${cslStyle}". Provide a built-in name, file path, or CSL XML.`);
126
+ }
127
+ const name = `custom-${hashString(xml)}`;
128
+ if (!templateExists(templates, name)) {
129
+ templates.add(name, xml);
130
+ }
131
+ return name;
132
+ }
133
+ renderCslEntries(entries) {
134
+ const cite = new Cite(entries.map((entry) => entry.csl));
135
+ const out = cite.format("bibliography", {
136
+ format: "html",
137
+ template: this.templateName,
138
+ lang: "en-US",
139
+ nosort: true,
140
+ asEntryArray: true,
141
+ });
142
+ if (!Array.isArray(out))
143
+ return [];
144
+ return out
145
+ .filter((item) => Array.isArray(item) && item.length >= 2)
146
+ .map(([id, html]) => [String(id), String(html)]);
147
+ }
148
+ decorateEntryHtml(entry, html, options) {
149
+ let out = html;
150
+ // Link the title text
151
+ const titleUrl = this.resolveTitleLink(entry, options.titleLink);
152
+ const title = entry.csl.title;
153
+ if (titleUrl && typeof title === "string" && title.trim()) {
154
+ out = this.linkTitle(out, title, titleUrl);
155
+ }
156
+ // Append badges
157
+ const badges = options.badges ?? [];
158
+ const badgeHtml = this.renderBadges(entry, badges);
159
+ if (badgeHtml) {
160
+ out += ` ${badgeHtml}`;
161
+ }
162
+ return out;
163
+ }
164
+ resolveTitleLink(entry, fields) {
165
+ const order = fields ?? ["url", "doi", "arxiv"];
166
+ for (const field of order) {
167
+ const value = entry.raw[field] ?? entry.csl[field.toUpperCase()] ?? entry.csl[field];
168
+ if (!value)
169
+ continue;
170
+ const raw = String(value).trim();
171
+ if (!raw)
172
+ continue;
173
+ if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw)) {
174
+ return sanitizeUrl(raw);
175
+ }
176
+ if (field === "doi") {
177
+ const doi = raw.replace(/^doi:\s*/i, "");
178
+ return sanitizeUrl(`https://doi.org/${doi}`);
179
+ }
180
+ if (field === "arxiv") {
181
+ return sanitizeUrl(`https://arxiv.org/abs/${raw.replace(/v\d+$/, "")}`);
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+ linkTitle(html, title, url) {
187
+ const pattern = buildHtmlTextPattern(title);
188
+ if (!pattern)
189
+ return html;
190
+ const regex = new RegExp(pattern);
191
+ const tokens = html.split(/(<[^>]*>)/g);
192
+ let insideAnchor = false;
193
+ let insideScript = false;
194
+ let insideStyle = false;
195
+ let linked = false;
196
+ const output = [];
197
+ for (const token of tokens) {
198
+ if (token.startsWith("<")) {
199
+ const lower = token.toLowerCase();
200
+ if (/^<a\b/.test(lower))
201
+ insideAnchor = true;
202
+ if (/^<\/a\b/.test(lower))
203
+ insideAnchor = false;
204
+ if (/^<script\b/.test(lower))
205
+ insideScript = true;
206
+ if (/^<\/script\b/.test(lower))
207
+ insideScript = false;
208
+ if (/^<style\b/.test(lower))
209
+ insideStyle = true;
210
+ if (/^<\/style\b/.test(lower))
211
+ insideStyle = false;
212
+ output.push(token);
213
+ continue;
214
+ }
215
+ if (linked || insideAnchor || insideScript || insideStyle) {
216
+ output.push(token);
217
+ continue;
218
+ }
219
+ const replaced = token.replace(regex, (match) => {
220
+ linked = true;
221
+ return `<a href="${escapeAttr(url)}">${match}</a>`;
222
+ });
223
+ output.push(replaced);
224
+ }
225
+ return output.join("");
226
+ }
227
+ renderBadges(entry, badges) {
228
+ const parts = [];
229
+ for (const badge of badges) {
230
+ const rawValue = entry.raw[badge.field] ?? entry.custom[badge.field];
231
+ if (rawValue == null)
232
+ continue;
233
+ const strValue = String(rawValue);
234
+ let insertValue;
235
+ if (badge.match) {
236
+ const m = strValue.match(badge.match);
237
+ if (!m)
238
+ continue;
239
+ insertValue = m[1] ?? m[0];
240
+ }
241
+ else {
242
+ insertValue = strValue;
243
+ }
244
+ const unsafeUrl = badge.url.replace(/\$1/g, insertValue);
245
+ const safeUrl = sanitizeUrl(unsafeUrl);
246
+ if (!safeUrl)
247
+ continue;
248
+ const cls = badge.className ? ` class="${escapeAttr(badge.className)}"` : "";
249
+ parts.push(`<a${cls} href="${escapeAttr(safeUrl)}">${escapeHtml(String(badge.label))}</a>`);
250
+ }
251
+ if (parts.length === 0)
252
+ return "";
253
+ return `<span class="bib-links">${parts.join(" ")}</span>`;
254
+ }
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // Standalone utilities (exported for reuse)
258
+ // ---------------------------------------------------------------------------
259
+ /**
260
+ * Auto-linkify bare `http(s)://` URLs in HTML that aren't already inside
261
+ * an `<a>` tag. Trailing punctuation (`.`, `,`, `;`, etc.) is kept outside
262
+ * the link.
263
+ */
264
+ export function linkifyBareUrls(html) {
265
+ const tokens = html.split(/(<[^>]*>)/g);
266
+ const urlRegex = /(https?:\/\/[^\s<>"',;)]+)/g;
267
+ let insideAnchor = false;
268
+ let insideScript = false;
269
+ let insideStyle = false;
270
+ const output = [];
271
+ for (const token of tokens) {
272
+ if (token.startsWith("<")) {
273
+ const lower = token.toLowerCase();
274
+ if (/^<a\b/.test(lower))
275
+ insideAnchor = true;
276
+ if (/^<\/a\b/.test(lower))
277
+ insideAnchor = false;
278
+ if (/^<script\b/.test(lower))
279
+ insideScript = true;
280
+ if (/^<\/script\b/.test(lower))
281
+ insideScript = false;
282
+ if (/^<style\b/.test(lower))
283
+ insideStyle = true;
284
+ if (/^<\/style\b/.test(lower))
285
+ insideStyle = false;
286
+ output.push(token);
287
+ continue;
288
+ }
289
+ if (insideAnchor || insideScript || insideStyle) {
290
+ output.push(token);
291
+ continue;
292
+ }
293
+ output.push(token.replace(urlRegex, (match) => {
294
+ const trimmed = match.replace(/[.,;:!?)]+$/, "");
295
+ const trailing = match.slice(trimmed.length);
296
+ return `<a href="${escapeAttr(trimmed)}">${trimmed}</a>${trailing}`;
297
+ }));
298
+ }
299
+ return output.join("");
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // Internal helpers
303
+ // ---------------------------------------------------------------------------
304
+ function readFileIfExists(input) {
305
+ if (!existsSync(input))
306
+ return null;
307
+ try {
308
+ if (!statSync(input).isFile())
309
+ return null;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ return readFileSync(input, "utf-8");
315
+ }
316
+ function maybeReadFile(input) {
317
+ return readFileIfExists(input) ?? input;
318
+ }
319
+ function looksLikeXml(input) {
320
+ return /^\s*</.test(input);
321
+ }
322
+ function templateExists(templates, name) {
323
+ if (typeof templates.has === "function" && templates.has(name))
324
+ return true;
325
+ if (Array.isArray(templates.list?.()) && templates.list().includes(name))
326
+ return true;
327
+ return false;
328
+ }
329
+ function unwrapCslEntry(entryHtml) {
330
+ const trimmed = entryHtml.trim();
331
+ const match = trimmed.match(/^<div\b([^>]*)>([\s\S]*)<\/div>$/s);
332
+ if (!match)
333
+ return null;
334
+ const attrs = match[1] ?? "";
335
+ if (!/\bclass\s*=\s*["'][^"']*\bcsl-entry\b/i.test(attrs))
336
+ return null;
337
+ return (match[2] ?? "").trim();
338
+ }
339
+ function sanitizeUrl(url) {
340
+ const trimmed = url.trim();
341
+ if (!trimmed)
342
+ return null;
343
+ if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) {
344
+ return trimmed;
345
+ }
346
+ return null;
347
+ }
348
+ function buildHtmlTextPattern(text) {
349
+ let pattern = "";
350
+ for (const ch of text) {
351
+ switch (ch) {
352
+ case "&":
353
+ pattern += "(?:&amp;|&#38;)";
354
+ break;
355
+ case "<":
356
+ pattern += "&lt;";
357
+ break;
358
+ case ">":
359
+ pattern += "&gt;";
360
+ break;
361
+ case '"':
362
+ pattern += "(?:&quot;|&#34;)";
363
+ break;
364
+ case "'":
365
+ pattern += "(?:&#39;|&apos;)";
366
+ break;
367
+ default:
368
+ pattern += escapeRegex(ch);
369
+ }
370
+ }
371
+ return pattern;
372
+ }
373
+ function escapeRegex(text) {
374
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
375
+ }
376
+ function escapeAttr(s) {
377
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
378
+ }
379
+ function escapeHtml(s) {
380
+ return s
381
+ .replace(/&/g, "&amp;")
382
+ .replace(/</g, "&lt;")
383
+ .replace(/>/g, "&gt;")
384
+ .replace(/"/g, "&quot;")
385
+ .replace(/'/g, "&#39;");
386
+ }
387
+ function renderAttributes(attrs) {
388
+ const parts = [];
389
+ for (const [k, v] of Object.entries(attrs)) {
390
+ if (v === true)
391
+ parts.push(k);
392
+ else if (v !== false)
393
+ parts.push(`${k}="${escapeAttr(String(v))}"`);
394
+ }
395
+ return parts.length ? " " + parts.join(" ") : "";
396
+ }
397
+ function hashString(input) {
398
+ let h = 0x811c9dc5;
399
+ for (let i = 0; i < input.length; i += 1) {
400
+ h ^= input.charCodeAt(i);
401
+ h = Math.imul(h, 0x01000193);
402
+ }
403
+ return (h >>> 0).toString(16);
404
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Declares a badge link to render alongside bibliography entries.
3
+ *
4
+ * The `url` is a template string where `$1` is replaced by the (optionally
5
+ * transformed) field value. When `match` is provided, the field value is
6
+ * tested against it first: if it doesn't match the badge is skipped; if it
7
+ * does, `$1` in the URL is replaced by the **first capture group** (or the
8
+ * full match if there is no capture group).
9
+ *
10
+ * @example
11
+ * // Simple prefix-style DOI badge:
12
+ * { field: 'doi', label: 'doi', url: 'https://doi.org/$1' }
13
+ *
14
+ * @example
15
+ * // Strip trailing version from arXiv identifiers:
16
+ * { field: 'arxiv', label: 'arXiv',
17
+ * url: 'https://arxiv.org/abs/$1',
18
+ * match: /^(.+?)(?:v\d+)?$/ }
19
+ *
20
+ * @example
21
+ * // Only link zbMATH entries that look like a Zbl number:
22
+ * { field: 'zbl', label: 'zbMATH',
23
+ * url: 'https://zbmath.org/?q=an:$1',
24
+ * match: /^(\d{4}\.\d{5})$/ }
25
+ */
26
+ export interface BadgeConfig {
27
+ /** BibTeX field name to read the value from. */
28
+ field: string;
29
+ /** Text to display inside the badge. */
30
+ label: string;
31
+ /** URL template — `$1` is replaced by the field value. */
32
+ url: string;
33
+ /**
34
+ * Optional regex applied to the field value.
35
+ *
36
+ * - If it **doesn't match**, the badge is skipped for that entry.
37
+ * - If it **matches**, `$1` in the URL is replaced by the first capture
38
+ * group (or the full match when there are no capture groups).
39
+ */
40
+ match?: RegExp;
41
+ /** CSS class name(s) for the badge `<a>` element. */
42
+ className?: string;
43
+ }
44
+ /** Options passed to {@link Bibliography.formatHtml}. */
45
+ export interface FormatOptions {
46
+ /**
47
+ * Fields to use for linking the title, checked in order.
48
+ * A `doi` value is expanded to `https://doi.org/<value>`, an `arxiv`
49
+ * value to `https://arxiv.org/abs/<value>`, and direct values are only
50
+ * accepted when they use `http`, `https`, or `mailto`.
51
+ *
52
+ * @default ['url', 'doi', 'arxiv']
53
+ */
54
+ titleLink?: string[];
55
+ /** Badge configurations to append to each entry. */
56
+ badges?: BadgeConfig[];
57
+ /**
58
+ * Wrapper list element.
59
+ * @default 'ol'
60
+ */
61
+ list?: "ol" | "ul" | "div";
62
+ /**
63
+ * HTML attributes for the wrapper element (e.g. `{ reversed: true }`).
64
+ * Boolean `true` renders as a valueless attribute.
65
+ *
66
+ * @default { reversed: true } (when list is 'ol')
67
+ */
68
+ listAttributes?: Record<string, string | boolean>;
69
+ /**
70
+ * Auto-linkify bare `http(s)://` URLs in the rendered output that aren't
71
+ * already inside `<a>`, `<script>`, or `<style>` tags.
72
+ *
73
+ * @default true
74
+ */
75
+ linkifyUrls?: boolean;
76
+ }
77
+ /** A bibliography entry enriched with custom BibTeX fields. */
78
+ export interface BibEntry {
79
+ /** The CSL-JSON object used by citation-js for formatting. */
80
+ csl: Record<string, any>;
81
+ /** The BibTeX citation key. */
82
+ key: string;
83
+ /** Publication year (extracted from CSL `issued`). */
84
+ year: number | null;
85
+ /**
86
+ * Custom BibTeX fields that were requested via `customFields`.
87
+ * Only fields that are present on the entry appear here.
88
+ */
89
+ custom: Record<string, string>;
90
+ /** The full raw BibTeX properties (unfiltered). */
91
+ raw: Record<string, any>;
92
+ }
93
+ /** Options for constructing a {@link Bibliography}. */
94
+ export interface BibliographyOptions {
95
+ /**
96
+ * BibTeX input — either a raw BibTeX string or a file path.
97
+ * When a file path is given, it is read synchronously at construction time.
98
+ */
99
+ data: string;
100
+ /**
101
+ * CSL style — a built-in template name (e.g. `'apa'`), raw CSL XML, or a
102
+ * file path to a `.csl` file. When a file path is given, it is read
103
+ * synchronously.
104
+ *
105
+ * When raw XML is provided, it is registered under an internal
106
+ * deterministic name (based on content hash) and used automatically by
107
+ * {@link Bibliography.formatHtml} and {@link Bibliography.formatEntry}.
108
+ *
109
+ * @default 'apa'
110
+ */
111
+ cslStyle?: string;
112
+ /**
113
+ * BibTeX field names to preserve through the citation-js pipeline.
114
+ * These are extracted from the raw BibTeX parse and made available on
115
+ * each {@link BibEntry} under `.custom`.
116
+ *
117
+ * Common examples: `['publication-status', 'arxiv', 'mrnumber', 'project']`.
118
+ */
119
+ customFields?: string[];
120
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Types
3
+ // ---------------------------------------------------------------------------
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@behackl/citation-js-extras",
3
+ "version": "0.1.0",
4
+ "description": "Preserve custom BibTeX fields through citation-js and render academic bibliographies with linked titles, badges, and more.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "citation-js",
21
+ "bibtex",
22
+ "bibliography",
23
+ "csl",
24
+ "custom-fields",
25
+ "academic"
26
+ ],
27
+ "author": "Benjamin Hackl",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/behackl/citation-js-extra.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/behackl/citation-js-extra/issues"
35
+ },
36
+ "homepage": "https://github.com/behackl/citation-js-extra#readme",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "sideEffects": false,
44
+ "peerDependencies": {
45
+ "citation-js": ">=0.7.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.2.3",
49
+ "citation-js": "^0.7.22",
50
+ "typescript": "^5.9.3",
51
+ "vitest": "^3.1.4"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc",
55
+ "test": "vitest run",
56
+ "test:watch": "vitest"
57
+ }
58
+ }