@behackl/citation-js-extras 0.1.0 → 0.2.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
@@ -1,5 +1,7 @@
1
1
  # @behackl/citation-js-extras
2
2
 
3
+ ![NPM Version](https://img.shields.io/npm/v/%40behackl%2Fcitation-js-extras)
4
+
3
5
  Preserve custom BibTeX fields through [citation-js](https://citation.js.org/) and render academic bibliographies with linked titles, configurable badges, and more.
4
6
 
5
7
  ## The problem
@@ -49,6 +51,70 @@ const html = bib.formatHtml(sorted, {
49
51
  });
50
52
  ```
51
53
 
54
+ ## Preserving mathematics
55
+
56
+ Citation.js normally converts TeX math to text, losing delimiters and potentially
57
+ complex expressions. Enable preservation before parsing:
58
+
59
+ ```ts
60
+ const bib = new Bibliography({
61
+ data: "./references.bib",
62
+ preserveMath: true,
63
+ });
64
+
65
+ // Restore HTML-escaped original TeX for client-side MathJax:
66
+ const html = bib.formatHtml(bib.entries);
67
+
68
+ // Or typeset at build time with your own synchronous renderer:
69
+ const rendered = bib.formatHtml(bib.entries, {
70
+ renderMath: (tex, { display }) => myMathRenderer(tex, display),
71
+ });
72
+ ```
73
+
74
+ `myMathRenderer` is an application-supplied function returning **trusted HTML**
75
+ (e.g. MathJax SVG or KaTeX output). No math renderer is bundled. Configure it for
76
+ untrusted TeX as appropriate; the callback output is inserted verbatim, not
77
+ sanitized. Exceptions propagate to the caller. `renderMath` also works with
78
+ `formatEntry`; it has no effect unless `preserveMath` was enabled.
79
+
80
+ Supported delimiters are `$…$`, `$$…$$`, `\(…\)`, and `\[…\]`.
81
+ Escape literal dollars as `\$`. Empty or unclosed expressions throw with the
82
+ citation key and field name. This is a delimiter scanner, not a TeX validator:
83
+ unsupported commands and mathematical validity are the renderer's responsibility.
84
+
85
+ Protection covers `title`, `subtitle`, `titleaddon`, `shorttitle`, `booktitle`,
86
+ `booksubtitle`, `booktitleaddon`, `maintitle`, `mainsubtitle`, `maintitleaddon`,
87
+ `journaltitle`, `journalsubtitle`, `journal`, `note`, `annote`, `abstract`, and
88
+ `howpublished`. Fields still need to be supported by Citation.js and the chosen
89
+ CSL style to appear in the output. Names, identifiers, URLs, and custom metadata
90
+ are not protected. BibTeX strings and concatenations are resolved before protection;
91
+ inherited cross-reference text is protected during conversion.
92
+
93
+ Original `.raw` and `.custom` values remain unchanged. With preservation enabled,
94
+ `.csl` contains internal placeholders: use the formatting methods for HTML and
95
+ raw fields for original source text, not `.csl` for plain-text exports. Placeholders
96
+ also mean CSL title-based sorting/disambiguation operates on protected text rather
97
+ than mathematical meaning. Existing caller-controlled ordering is retained.
98
+
99
+ Restoration runs after title linking, badges, and URL linkification. Neither
100
+ renderer output nor restored TeX is fed back through these HTML helpers. Disabling
101
+ preservation (the default) retains the previous behavior.
102
+
103
+ ## Development checks
104
+
105
+ ```sh
106
+ pnpm install --frozen-lockfile
107
+ pnpm test # Unit tests and actual MathJax SVG integration (base + AMS)
108
+ pnpm test:package # Build, pack, install into a temporary consumer, and test exports
109
+ ```
110
+
111
+ The package check validates ESM imports, TypeScript declarations under both
112
+ NodeNext and Bundler resolution, and MathJax rendering through the installed
113
+ tarball. Its temporary consumer is removed afterwards. Installation prefers the
114
+ local cache but may need registry access on a fresh machine. CI runs both checks.
115
+ MathJax is a development-only dependency, not a runtime dependency for consumers.
116
+ Use Node 24 LTS for these development checks, matching CI and publishing.
117
+
52
118
  ## API
53
119
 
54
120
  ### `new Bibliography(options)`
@@ -58,6 +124,7 @@ const html = bib.formatHtml(sorted, {
58
124
  | `data` | `string` | BibTeX input — a raw string or a file path. |
59
125
  | `cslStyle` | `string?` | CSL style — a registered template name, raw XML, or a file path. Defaults to `'apa'`. |
60
126
  | `customFields` | `string[]?` | BibTeX field names to preserve. These appear on each entry under `.custom`. |
127
+ | `preserveMath` | `boolean?` | Preserve math in display-text fields through CSL formatting. Defaults to `false`. |
61
128
 
62
129
  ### `bib.entries`
63
130
 
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { BibEntry, BibliographyOptions, FormatOptions } from "./types.js";
2
- export type { BadgeConfig, BibEntry, BibliographyOptions, FormatOptions } from "./types.js";
2
+ export type { BadgeConfig, BibEntry, BibliographyOptions, FormatOptions, MathRenderer } from "./types.js";
3
3
  export declare class Bibliography {
4
4
  /** The CSL template name to use for formatting. */
5
5
  readonly templateName: string;
6
6
  /** All parsed entries. */
7
7
  readonly entries: BibEntry[];
8
8
  private readonly customFieldNames;
9
+ private readonly math?;
9
10
  constructor(options: BibliographyOptions);
10
11
  /**
11
12
  * Return entries whose custom fields match **all** given key/value pairs.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import Cite from "citation-js";
3
+ import { MathProtector } from "./math.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // Bibliography class
5
6
  // ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export class Bibliography {
9
10
  /** All parsed entries. */
10
11
  entries;
11
12
  customFieldNames;
13
+ math;
12
14
  constructor(options) {
13
15
  const bibData = maybeReadFile(options.data);
14
16
  this.customFieldNames = options.customFields ?? [];
@@ -21,7 +23,15 @@ export class Bibliography {
21
23
  for (const entry of rawEntries) {
22
24
  rawMap.set(entry.label, entry.properties);
23
25
  }
24
- const cite = new Cite(bibData);
26
+ // Protect resolved raw fields, after BibTeX strings/concatenations are parsed
27
+ // but before the lossy TeX-to-CSL conversion. Keep the original raw map intact.
28
+ this.math = options.preserveMath
29
+ ? new MathProtector(JSON.stringify(rawEntries)) : undefined;
30
+ const cite = this.math
31
+ ? new Cite(rawEntries.map(entry => ({
32
+ ...entry, properties: this.math.protect(entry.properties, entry.label),
33
+ })))
34
+ : new Cite(bibData);
25
35
  this.entries = cite.data.map((csl) => {
26
36
  const key = String(csl["citation-key"] || csl.id);
27
37
  const raw = rawMap.get(key) ?? {};
@@ -77,7 +87,8 @@ export class Bibliography {
77
87
  const rendered = this.renderCslEntries([entry]);
78
88
  const raw = rendered[0]?.[1] ?? "";
79
89
  const innerHtml = unwrapCslEntry(raw) ?? raw.trim();
80
- return this.decorateEntryHtml(entry, innerHtml, options);
90
+ const decorated = this.decorateEntryHtml(entry, innerHtml, options);
91
+ return this.math ? this.math.restore(decorated, options.renderMath) : decorated;
81
92
  }
82
93
  /**
83
94
  * Format a list of entries as a complete HTML bibliography.
@@ -107,7 +118,7 @@ export class Bibliography {
107
118
  if (options.linkifyUrls !== false) {
108
119
  html = linkifyBareUrls(html);
109
120
  }
110
- return html;
121
+ return this.math ? this.math.restore(html, options.renderMath) : html;
111
122
  }
112
123
  // -------------------------------------------------------------------------
113
124
  // Private helpers
package/dist/math.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { MathRenderer } from "./types.js";
2
+ export declare class MathProtector {
3
+ private readonly expressions;
4
+ private readonly tokensBySource;
5
+ private readonly prefix;
6
+ constructor(source: string);
7
+ protect(properties: Record<string, any>, key: string): Record<string, any>;
8
+ private scan;
9
+ restore(html: string, render?: MathRenderer): string;
10
+ }
package/dist/math.js ADDED
@@ -0,0 +1,77 @@
1
+ /** Text fields only: identifiers, URLs, names and custom metadata are untouched. */
2
+ const TEXT_FIELDS = new Set([
3
+ "title", "subtitle", "titleaddon", "shorttitle", "booktitle", "booksubtitle",
4
+ "booktitleaddon", "maintitle", "mainsubtitle", "maintitleaddon", "journaltitle",
5
+ "journalsubtitle", "journal", "note", "annote", "abstract", "howpublished",
6
+ ]);
7
+ export class MathProtector {
8
+ expressions = new Map();
9
+ tokensBySource = new Map();
10
+ prefix;
11
+ constructor(source) {
12
+ let prefix = "bibmathplaceholder";
13
+ while (source.toLowerCase().includes(prefix))
14
+ prefix += "x";
15
+ this.prefix = prefix;
16
+ }
17
+ protect(properties, key) {
18
+ return Object.fromEntries(Object.entries(properties).map(([field, value]) => [
19
+ field,
20
+ TEXT_FIELDS.has(field) && typeof value === "string"
21
+ ? this.scan(value, `${key}.${field}`) : value,
22
+ ]));
23
+ }
24
+ scan(value, context) {
25
+ let out = "";
26
+ for (let i = 0; i < value.length;) {
27
+ const opener = value.startsWith("$$", i) ? "$$"
28
+ : value[i] === "$" ? "$"
29
+ : value.startsWith("\\(", i) ? "\\("
30
+ : value.startsWith("\\[", i) ? "\\[" : null;
31
+ if (!opener) {
32
+ // Consume escaped characters together, including literal dollars.
33
+ const length = value[i] === "\\" && i + 1 < value.length ? 2 : 1;
34
+ out += value.slice(i, i + length);
35
+ i += length;
36
+ continue;
37
+ }
38
+ const closer = opener === "\\(" ? "\\)" : opener === "\\[" ? "\\]" : opener;
39
+ let end = i + opener.length;
40
+ while (end < value.length && !value.startsWith(closer, end)) {
41
+ end += value[end] === "\\" ? 2 : 1;
42
+ }
43
+ if (end >= value.length)
44
+ throw new Error(`Unclosed math delimiter ${opener} in ${context}`);
45
+ const tex = value.slice(i + opener.length, end);
46
+ if (!tex.trim())
47
+ throw new Error(`Empty math expression in ${context}`);
48
+ const source = value.slice(i, end + closer.length);
49
+ // Identical source must remain identical to citeproc (e.g. title grouping).
50
+ let token = this.tokensBySource.get(source);
51
+ if (!token) {
52
+ token = `${this.prefix}${this.expressions.size}end`;
53
+ this.tokensBySource.set(source, token);
54
+ this.expressions.set(token, { tex, source, display: opener === "$$" || opener === "\\[" });
55
+ }
56
+ out += token;
57
+ i = end + closer.length;
58
+ }
59
+ return out;
60
+ }
61
+ restore(html, render) {
62
+ const pattern = new RegExp(`${this.prefix}\\d+end`, "gi");
63
+ // Only replace text, never attribute values. Renderer output is inserted last.
64
+ return html.split(/(<[^>]*>)/g).map(part => part.startsWith("<") ? part
65
+ : part.replace(pattern, token => {
66
+ const expression = this.expressions.get(token.toLowerCase());
67
+ if (!expression)
68
+ throw new Error(`Unknown math placeholder: ${token}`);
69
+ return render ? render(expression.tex, { display: expression.display })
70
+ : escapeHtml(expression.source);
71
+ })).join("");
72
+ }
73
+ }
74
+ function escapeHtml(value) {
75
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
76
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
77
+ }
package/dist/types.d.ts CHANGED
@@ -41,8 +41,16 @@ export interface BadgeConfig {
41
41
  /** CSS class name(s) for the badge `<a>` element. */
42
42
  className?: string;
43
43
  }
44
+ /** Synchronous renderer returning trusted HTML. Sanitize untrusted renderer output. */
45
+ export type MathRenderer = (tex: string, context: {
46
+ display: boolean;
47
+ }) => string;
44
48
  /** Options passed to {@link Bibliography.formatHtml}. */
45
49
  export interface FormatOptions {
50
+ /** Render protected math as trusted HTML; requires preserveMath at construction.
51
+ * Without this callback, escaped original TeX delimiters are restored.
52
+ */
53
+ renderMath?: MathRenderer;
46
54
  /**
47
55
  * Fields to use for linking the title, checked in order.
48
56
  * A `doi` value is expanded to `https://doi.org/<value>`, an `arxiv`
@@ -92,6 +100,11 @@ export interface BibEntry {
92
100
  }
93
101
  /** Options for constructing a {@link Bibliography}. */
94
102
  export interface BibliographyOptions {
103
+ /** Preserve math in supported display-text fields before CSL conversion.
104
+ * Opt-in; entries' CSL text contains internal placeholders until HTML formatting.
105
+ * Raw/custom fields remain unchanged. Unclosed or empty math throws.
106
+ */
107
+ preserveMath?: boolean;
95
108
  /**
96
109
  * BibTeX input — either a raw BibTeX string or a file path.
97
110
  * When a file path is given, it is read synchronously at construction time.
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@behackl/citation-js-extras",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
7
7
  "types": "./dist/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "import": "./dist/index.js",
11
- "types": "./dist/index.d.ts"
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
12
  }
13
13
  },
14
14
  "files": [
@@ -16,6 +16,13 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "test:package": "pnpm build && node scripts/test-package.mjs",
24
+ "prepublishOnly": "tsc"
25
+ },
19
26
  "keywords": [
20
27
  "citation-js",
21
28
  "bibtex",
@@ -28,12 +35,12 @@
28
35
  "license": "MIT",
29
36
  "repository": {
30
37
  "type": "git",
31
- "url": "git+https://github.com/behackl/citation-js-extra.git"
38
+ "url": "git+https://github.com/behackl/citation-js-extras.git"
32
39
  },
33
40
  "bugs": {
34
- "url": "https://github.com/behackl/citation-js-extra/issues"
41
+ "url": "https://github.com/behackl/citation-js-extras/issues"
35
42
  },
36
- "homepage": "https://github.com/behackl/citation-js-extra#readme",
43
+ "homepage": "https://github.com/behackl/citation-js-extras#readme",
37
44
  "publishConfig": {
38
45
  "access": "public"
39
46
  },
@@ -41,18 +48,20 @@
41
48
  "node": ">=18"
42
49
  },
43
50
  "sideEffects": false,
51
+ "packageManager": "pnpm@10.29.1",
44
52
  "peerDependencies": {
45
53
  "citation-js": ">=0.7.0"
46
54
  },
47
55
  "devDependencies": {
56
+ "@mathjax/src": "4.1.0",
48
57
  "@types/node": "^25.2.3",
49
58
  "citation-js": "^0.7.22",
50
59
  "typescript": "^5.9.3",
51
60
  "vitest": "^3.1.4"
52
61
  },
53
- "scripts": {
54
- "build": "tsc",
55
- "test": "vitest run",
56
- "test:watch": "vitest"
62
+ "pnpm": {
63
+ "onlyBuiltDependencies": [
64
+ "esbuild"
65
+ ]
57
66
  }
58
- }
67
+ }