@behackl/citation-js-extras 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,6 +96,7 @@ See [Mathematics and sanitizing](docs/math.md).
96
96
  | how entries read | the CSL style (`cslStyle`) and locale (`lang`) | [Layout](docs/layout.md#styles-and-locales) |
97
97
  | the markup around entries | `list`, `listAttributes`, `itemAttributes`, `badgeListClassName` | [Layout](docs/layout.md#markup) |
98
98
  | a layout of your own | `links(entry)`, `appendBadges`, `wrapVariable` | [Layout](docs/layout.md#laying-out-entries-yourself) |
99
+ | a "Copy BibTeX" button | `bibtex(entry)` | [Layout](docs/layout.md#copying-an-entrys-bibtex) |
99
100
  | what reaches the page | `sanitize`, `renderMath` | [Mathematics](docs/math.md) |
100
101
  | order and selection | `sort`, `filter`, or array methods on `bib.entries` | [API](docs/api.md#bibsortentries-options) |
101
102
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { BibEntry, BibliographyOptions, EntryLinks, FormatOptions } from "./types.js";
2
- export type { BadgeConfig, BadgeFunction, BibEntry, BibliographyOptions, EntryLinks, FormatDefaults, FormatOptions, HtmlAttributes, Link, MathRenderer, } from "./types.js";
1
+ import type { BibEntry, BibliographyOptions, BibtexOptions, EntryLinks, FormatOptions } from "./types.js";
2
+ export type { BadgeConfig, BadgeFunction, BadgeLink, BibEntry, BibliographyOptions, BibtexOptions, EntryLinks, FormatDefaults, FormatOptions, HtmlAttributes, Link, MathRenderer, TitleLink, } from "./types.js";
3
3
  /**
4
4
  * Ready-made badges for the identifiers of mathematical bibliographies. Their
5
5
  * matchers accept the spellings found in real exports (`doi:10…`,
@@ -44,6 +44,7 @@ export declare class Bibliography {
44
44
  /** All parsed entries. */
45
45
  readonly entries: BibEntry[];
46
46
  private readonly customFieldNames;
47
+ private readonly bibtexEntries;
47
48
  private readonly math?;
48
49
  /** Formatting defaults from the constructor; each call may override them. */
49
50
  private readonly formatDefaults;
@@ -83,6 +84,21 @@ export declare class Bibliography {
83
84
  * rendering badges yourself, pair it with `appendBadges: false`.
84
85
  */
85
86
  links(entry: BibEntry, options?: FormatOptions): EntryLinks;
87
+ /**
88
+ * The entry as BibTeX that stands on its own, e.g. for readers to copy:
89
+ * `@string` abbreviations are resolved and the fields of `crossref` parents
90
+ * filled in using biblatex's title-remapping rules. Missing parents leave
91
+ * `crossref` unresolved, so the copy may still require its parent.
92
+ * Field values are the TeX of the `.bib` file. Requires this bibliography's
93
+ * original key and raw object; shallow entry copies are accepted.
94
+ */
95
+ bibtex(entry: BibEntry, options?: BibtexOptions): string;
96
+ /**
97
+ * An entry's fields with those inherited through `crossref`, using the same
98
+ * biblatex title-remapping rules as citation-js (`plugin-bibtex`'s
99
+ * `mapping/crossref.js`). Keep the mapping and exclusion tests in sync.
100
+ */
101
+ private withInherited;
86
102
  /** Per-call options win over the defaults given to the constructor. */
87
103
  private mergeOptions;
88
104
  /**
package/dist/index.js CHANGED
@@ -80,6 +80,7 @@ export class Bibliography {
80
80
  /** All parsed entries. */
81
81
  entries;
82
82
  customFieldNames;
83
+ bibtexEntries = new Map();
83
84
  math;
84
85
  /** Formatting defaults from the constructor; each call may override them. */
85
86
  formatDefaults;
@@ -97,12 +98,11 @@ export class Bibliography {
97
98
  // Two-pass parse: raw (preserves all fields) + CSL (for formatting)
98
99
  const { plugins } = Cite;
99
100
  const rawEntries = plugins.input.chainLink(bibData);
100
- const rawMap = new Map();
101
101
  const duplicates = new Set();
102
102
  for (const entry of rawEntries) {
103
- if (rawMap.has(entry.label))
103
+ if (this.bibtexEntries.has(entry.label))
104
104
  duplicates.add(entry.label);
105
- rawMap.set(entry.label, entry.properties);
105
+ this.bibtexEntries.set(entry.label, entry);
106
106
  }
107
107
  // Raw fields are merged by key, so a duplicate would silently give one
108
108
  // entry the other's fields.
@@ -120,7 +120,7 @@ export class Bibliography {
120
120
  : new Cite(bibData);
121
121
  this.entries = cite.data.map((csl) => {
122
122
  const key = String(csl["citation-key"] || csl.id);
123
- const raw = rawMap.get(key) ?? {};
123
+ const raw = this.bibtexEntries.get(key)?.properties ?? {};
124
124
  const custom = {};
125
125
  for (const f of this.customFieldNames) {
126
126
  const value = raw[f.toLowerCase()] ?? raw[f];
@@ -205,9 +205,62 @@ export class Bibliography {
205
205
  links(entry, options = {}) {
206
206
  return this.resolveLinks(entry, this.mergeOptions(options));
207
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
+ }
208
231
  // -------------------------------------------------------------------------
209
232
  // Private helpers
210
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.
238
+ */
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;
263
+ }
211
264
  /** Per-call options win over the defaults given to the constructor. */
212
265
  mergeOptions(options) {
213
266
  const merged = { ...options };
@@ -397,6 +450,39 @@ export function linkifyBareUrls(html) {
397
450
  // ---------------------------------------------------------------------------
398
451
  // Fields and links
399
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
+ }
400
486
  /**
401
487
  * The value of a BibTeX field, and the only place fields are read. Names are
402
488
  * case-insensitive. A field named like an eprint archive falls back to
@@ -497,7 +583,7 @@ function decorate(html, titleLinked, links, options) {
497
583
  if (links.title && !titleLinked)
498
584
  out += ` ${linkHtml(links.title, escapeHtml(links.title.url), options)}`;
499
585
  if (links.badges.length && options.appendBadges !== false) {
500
- const badges = links.badges.map(link => linkHtml(link, escapeHtml(link.label ?? ""), options));
586
+ const badges = links.badges.map(link => linkHtml(link, escapeHtml(link.label), options));
501
587
  out += ` <span class="${escapeAttr(options.badgeListClassName ?? "bib-links")}">${badges.join(" ")}</span>`;
502
588
  }
503
589
  return out;
@@ -626,7 +712,7 @@ function renderAttributes(attrs) {
626
712
  for (const [k, v] of Object.entries(attrs)) {
627
713
  if (v === true)
628
714
  parts.push(k);
629
- else if (v !== false)
715
+ else if (v !== false && v !== undefined)
630
716
  parts.push(`${k}="${escapeAttr(String(v))}"`);
631
717
  }
632
718
  return parts.length ? " " + parts.join(" ") : "";
package/dist/types.d.ts CHANGED
@@ -68,8 +68,11 @@ export interface BadgeConfig {
68
68
  }
69
69
  /** Computes a badge's label or URL from the matched field value. */
70
70
  export type BadgeFunction = (value: string, entry: BibEntry) => string;
71
- /** HTML attributes; `true` renders a valueless attribute, `false` omits it. */
72
- export type HtmlAttributes = Record<string, string | boolean>;
71
+ /**
72
+ * HTML attributes; `true` renders a valueless attribute, `false` and
73
+ * `undefined` omit it.
74
+ */
75
+ export type HtmlAttributes = Record<string, string | boolean | undefined>;
73
76
  /** A link the library resolved for an entry: its title link or a badge. */
74
77
  export interface Link {
75
78
  kind: "title" | "badge";
@@ -84,10 +87,21 @@ export interface Link {
84
87
  className?: string;
85
88
  entry: BibEntry;
86
89
  }
90
+ /** An entry's title link. */
91
+ export interface TitleLink extends Link {
92
+ kind: "title";
93
+ label?: never;
94
+ className?: never;
95
+ }
96
+ /** A badge link, which always has a label. */
97
+ export interface BadgeLink extends Link {
98
+ kind: "badge";
99
+ label: string;
100
+ }
87
101
  /** The links of one entry, as returned by `Bibliography.links`. */
88
102
  export interface EntryLinks {
89
- title?: Link;
90
- badges: Link[];
103
+ title?: TitleLink;
104
+ badges: BadgeLink[];
91
105
  }
92
106
  /** Synchronous renderer returning trusted HTML. Sanitize untrusted renderer output. */
93
107
  export type MathRenderer = (tex: string, context: {
@@ -174,7 +188,7 @@ export interface FormatOptions {
174
188
  * `className`; an `href` replaces the URL (it must still be `http(s)`,
175
189
  * `mailto` or relative, or the link is dropped).
176
190
  */
177
- linkAttributes?: (link: Link) => HtmlAttributes;
191
+ linkAttributes?: (link: TitleLink | BadgeLink) => HtmlAttributes;
178
192
  /**
179
193
  * Wrap the rendered HTML of each CSL variable in the bibliography (and the
180
194
  * title outside its link), e.g. to mark it for CSS:
@@ -200,6 +214,11 @@ export interface FormatOptions {
200
214
  */
201
215
  linkifyUrls?: boolean;
202
216
  }
217
+ /** Options for {@link Bibliography.bibtex}. */
218
+ export interface BibtexOptions {
219
+ /** Fields to leave out, case-insensitively, e.g. private ones like `status` or `file`. */
220
+ exclude?: string[];
221
+ }
203
222
  /** A bibliography entry enriched with custom BibTeX fields. */
204
223
  export interface BibEntry {
205
224
  /** The CSL-JSON object used by citation-js for formatting. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@behackl/citation-js-extras",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Preserve custom BibTeX fields through citation-js and render academic bibliographies with linked titles, badges, and more.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "tsc",
21
21
  "test": "vitest run",
22
+ "typecheck": "tsc -p tsconfig.test.json",
22
23
  "test:watch": "vitest",
23
24
  "test:package": "pnpm build && node scripts/test-package.mjs",
24
25
  "prepublishOnly": "tsc"