@citestyle/bibtex 0.0.2

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 Proximify
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,115 @@
1
+ # @citestyle/bibtex
2
+
3
+ BibTeX parser and serializer. Converts between BibTeX and CSL-JSON, the standard input format for Citestyle.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @citestyle/bibtex
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Parse BibTeX to CSL-JSON
14
+
15
+ ```js
16
+ import { parseBibtex } from '@citestyle/bibtex'
17
+
18
+ const bibtex = `
19
+ @article{smith2024,
20
+ author = {Smith, John and de la Cruz, Maria},
21
+ title = {A Study of {DNA} Replication},
22
+ journal = {Nature},
23
+ year = {2024},
24
+ volume = {620},
25
+ pages = {100--108},
26
+ doi = {10.1038/example}
27
+ }
28
+ `
29
+
30
+ const items = parseBibtex(bibtex)
31
+ console.log(items[0].title) // "A Study of DNA Replication"
32
+ console.log(items[0].author[0].family) // "Smith"
33
+ console.log(items[0].author[1]['non-dropping-particle']) // "de la"
34
+ console.log(items[0].page) // "100-108"
35
+ ```
36
+
37
+ ### Export CSL-JSON to BibTeX
38
+
39
+ ```js
40
+ import { exportBibtex } from '@citestyle/bibtex'
41
+
42
+ const items = [{
43
+ id: 'smith2024',
44
+ type: 'article-journal',
45
+ title: 'A Study of DNA Replication',
46
+ author: [{ family: 'Smith', given: 'John' }],
47
+ issued: { 'date-parts': [[2024]] },
48
+ 'container-title': 'Nature',
49
+ volume: '620',
50
+ page: '100-108',
51
+ DOI: '10.1038/example',
52
+ }]
53
+
54
+ const output = exportBibtex(items)
55
+ // @article{smith2024,
56
+ // author = {Smith, John},
57
+ // title = {A Study of DNA Replication},
58
+ // journal = {Nature},
59
+ // ...
60
+ // }
61
+ ```
62
+
63
+ ### Convert LaTeX commands to Unicode
64
+
65
+ ```js
66
+ import { convertLatex } from '@citestyle/bibtex'
67
+
68
+ convertLatex("Universit\\\"at M\\\"unchen") // "Universitat Munchen" → "Universitat München"
69
+ convertLatex("Sch\\\"onfinkel") // "Schonfinkel" → "Schönfinkel"
70
+ convertLatex("{\\ss}") // "ß"
71
+ convertLatex("\\o") // "ø"
72
+ ```
73
+
74
+ ### Pipeline: BibTeX to formatted bibliography
75
+
76
+ ```js
77
+ import { parseBibtex } from '@citestyle/bibtex'
78
+ import { createRegistry } from '@citestyle/registry'
79
+ import * as apa from '@citestyle/styles/apa'
80
+
81
+ const bibtex = readFileSync('references.bib', 'utf-8')
82
+ const items = parseBibtex(bibtex)
83
+
84
+ const registry = createRegistry(apa)
85
+ registry.addItems(items)
86
+
87
+ const bibliography = registry.getBibliography()
88
+ bibliography.forEach(entry => console.log(entry.html))
89
+ ```
90
+
91
+ ## API
92
+
93
+ ### `parseBibtex(str)`
94
+
95
+ Parse a BibTeX string into an array of CSL-JSON items.
96
+
97
+ **Supported entry types:** `@article`, `@book`, `@inproceedings`, `@incollection`, `@inbook`, `@phdthesis`, `@mastersthesis`, `@misc`, `@techreport`, `@unpublished`, `@proceedings`, `@manual`, `@conference`, `@booklet`, `@online`.
98
+
99
+ **Features:**
100
+ - LaTeX accent/command conversion (120+ mappings)
101
+ - `@string` abbreviation expansion
102
+ - `#` concatenation of string values
103
+ - Braced and quoted string values
104
+ - Month abbreviations (jan-dec)
105
+ - Name parsing with particles (`von`, `de`, `de la`) and suffixes (`Jr.`, `III`)
106
+ - Corporate/institutional authors via double braces (`{{World Health Organization}}`)
107
+ - Nested brace stripping
108
+
109
+ ### `exportBibtex(items)`
110
+
111
+ Serialize an array of CSL-JSON items to a BibTeX string. Produces clean output with en-dash page ranges and proper name formatting.
112
+
113
+ ### `convertLatex(str)`
114
+
115
+ Convert LaTeX accent sequences and special commands to Unicode characters. Handles `\"`, `\'`, `\^`, `\~`, `\c`, `\v`, and special commands like `\ss`, `\o`, `\ae`.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@citestyle/bibtex",
3
+ "version": "0.0.2",
4
+ "description": "BibTeX ↔ CSL-JSON parser and serializer",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types.d.ts",
9
+ "import": "./src/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "types.d.ts",
15
+ "README.md"
16
+ ],
17
+ "dependencies": {
18
+ "@citestyle/types": "0.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "vitest": "^3.1.1"
22
+ },
23
+ "engines": {
24
+ "node": ">=20.19.0"
25
+ },
26
+ "license": "MIT",
27
+ "scripts": {
28
+ "test": "vitest run --passWithNoTests",
29
+ "lint": "echo 'TODO: configure linter'"
30
+ }
31
+ }
package/src/export.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Serialize CSL-JSON items as a BibTeX string.
3
+ *
4
+ * Produces clean, readable BibTeX with proper field ordering,
5
+ * en-dash page ranges, and name formatting.
6
+ *
7
+ * @param {object[]} items - CSL-JSON items
8
+ * @returns {string} BibTeX source
9
+ */
10
+
11
+ // ── CSL type → BibTeX entry type mapping ────────────────────────────────────
12
+
13
+ const REVERSE_TYPE_MAP = {
14
+ 'article-journal': 'article',
15
+ 'article-magazine': 'article',
16
+ 'article-newspaper': 'article',
17
+ 'article': 'misc',
18
+ 'book': 'book',
19
+ 'chapter': 'incollection',
20
+ 'manuscript': 'unpublished',
21
+ 'pamphlet': 'booklet',
22
+ 'paper-conference': 'inproceedings',
23
+ 'report': 'techreport',
24
+ 'thesis': 'phdthesis',
25
+ 'webpage': 'misc',
26
+ 'motion_picture': 'misc',
27
+ 'graphic': 'misc',
28
+ 'song': 'misc',
29
+ 'software': 'misc',
30
+ 'dataset': 'misc',
31
+ 'patent': 'misc',
32
+ 'bill': 'misc',
33
+ 'legislation': 'misc',
34
+ 'legal_case': 'misc',
35
+ }
36
+
37
+ const MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
38
+
39
+ // ── Name serialization ──────────────────────────────────────────────────────
40
+
41
+ function serializeName(name) {
42
+ if (name.literal) return `{${name.literal}}`
43
+
44
+ const parts = []
45
+ // "von Last, Jr., First" format
46
+ const particle = name['non-dropping-particle'] || name['dropping-particle'] || ''
47
+ const family = (particle ? particle + ' ' : '') + (name.family || '')
48
+
49
+ if (name.suffix) {
50
+ // Last, Jr., First
51
+ parts.push(family)
52
+ parts.push(name.suffix)
53
+ if (name.given) parts.push(name.given)
54
+ return parts.join(', ')
55
+ }
56
+
57
+ if (name.given) {
58
+ return `${family}, ${name.given}`
59
+ }
60
+
61
+ return family
62
+ }
63
+
64
+ function serializeNames(names) {
65
+ if (!names || !names.length) return null
66
+ return names.map(serializeName).join(' and ')
67
+ }
68
+
69
+ // ── Field value escaping ────────────────────────────────────────────────────
70
+
71
+ function escapeValue(str) {
72
+ if (str == null) return null
73
+ return String(str)
74
+ }
75
+
76
+ function wrapBraces(str) {
77
+ return `{${str}}`
78
+ }
79
+
80
+ // ── Generate citation key ───────────────────────────────────────────────────
81
+
82
+ function generateKey(item) {
83
+ if (item.id && typeof item.id === 'string' && !/\s/.test(item.id)) return item.id
84
+
85
+ let key = ''
86
+ if (item.author && item.author.length) {
87
+ const first = item.author[0]
88
+ key += (first.family || first.literal || 'unknown').replace(/\s+/g, '')
89
+ }
90
+
91
+ const year = item.issued?.['date-parts']?.[0]?.[0]
92
+ if (year) key += year
93
+ return key || 'item'
94
+ }
95
+
96
+ // ── Public API ──────────────────────────────────────────────────────────────
97
+
98
+ export function exportBibtex(items) {
99
+ if (!items || !items.length) return ''
100
+
101
+ return items.map(item => {
102
+ let entryType = REVERSE_TYPE_MAP[item.type] || 'misc'
103
+
104
+ // Refine thesis type
105
+ if (item.type === 'thesis' && item.genre) {
106
+ const genre = item.genre.toLowerCase()
107
+ if (genre.includes('master')) entryType = 'mastersthesis'
108
+ else entryType = 'phdthesis'
109
+ }
110
+
111
+ const key = generateKey(item)
112
+ const fields = []
113
+
114
+ // Names
115
+ if (item.author) {
116
+ const val = serializeNames(item.author)
117
+ if (val) fields.push(['author', wrapBraces(val)])
118
+ }
119
+ if (item.editor) {
120
+ const val = serializeNames(item.editor)
121
+ if (val) fields.push(['editor', wrapBraces(val)])
122
+ }
123
+
124
+ // Title
125
+ if (item.title) fields.push(['title', wrapBraces(escapeValue(item.title))])
126
+
127
+ // Container title → journal or booktitle
128
+ if (item['container-title']) {
129
+ const bibField = entryType === 'article' ? 'journal' : 'booktitle'
130
+ fields.push([bibField, wrapBraces(escapeValue(item['container-title']))])
131
+ }
132
+
133
+ // Date
134
+ const dateParts = item.issued?.['date-parts']?.[0]
135
+ if (dateParts) {
136
+ if (dateParts[0]) fields.push(['year', wrapBraces(String(dateParts[0]))])
137
+ if (dateParts[1] && dateParts[1] >= 1 && dateParts[1] <= 12) {
138
+ fields.push(['month', MONTH_NAMES[dateParts[1] - 1]])
139
+ }
140
+ }
141
+
142
+ // Simple fields
143
+ const simpleFields = [
144
+ ['volume', 'volume'],
145
+ ['issue', 'number'],
146
+ ['page', 'pages'],
147
+ ['DOI', 'doi'],
148
+ ['URL', 'url'],
149
+ ['ISBN', 'isbn'],
150
+ ['ISSN', 'issn'],
151
+ ['publisher', 'publisher'],
152
+ ['publisher-place', 'address'],
153
+ ['edition', 'edition'],
154
+ ['collection-title', 'series'],
155
+ ['note', 'note'],
156
+ ['abstract', 'abstract'],
157
+ ['keyword', 'keywords'],
158
+ ['language', 'language'],
159
+ ['chapter-number', 'chapter'],
160
+ ]
161
+
162
+ for (const [cslField, bibField] of simpleFields) {
163
+ if (item[cslField]) {
164
+ let val = String(item[cslField])
165
+ // Normalize page ranges: – → --
166
+ if (bibField === 'pages') val = val.replace(/–/g, '--')
167
+ fields.push([bibField, wrapBraces(val)])
168
+ }
169
+ }
170
+
171
+ // Publisher from school/institution
172
+ if (!item.publisher && entryType === 'phdthesis' || entryType === 'mastersthesis') {
173
+ // Already handled above via publisher
174
+ }
175
+
176
+ // Genre for thesis
177
+ if (item.genre && entryType !== 'phdthesis' && entryType !== 'mastersthesis') {
178
+ fields.push(['type', wrapBraces(escapeValue(item.genre))])
179
+ }
180
+
181
+ // Format output
182
+ const fieldStr = fields.map(([k, v]) => ` ${k} = ${v}`).join(',\n')
183
+ return `@${entryType}{${key},\n${fieldStr}\n}`
184
+ }).join('\n\n') + '\n'
185
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @citestyle/bibtex
3
+ *
4
+ * BibTeX ↔ CSL-JSON conversion.
5
+ */
6
+
7
+ export { parseBibtex, convertLatex } from './parse.js'
8
+ export { exportBibtex } from './export.js'
package/src/parse.js ADDED
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Parse a BibTeX string into an array of CSL-JSON items.
3
+ *
4
+ * Handles: @article, @book, @inproceedings, @incollection, @inbook,
5
+ * @phdthesis, @mastersthesis, @misc, @techreport, @unpublished,
6
+ * @proceedings, @manual, @conference, @booklet, @online.
7
+ *
8
+ * Features: LaTeX accent/command conversion, @string abbreviations,
9
+ * # concatenation, braced/quoted string values, month abbreviations.
10
+ *
11
+ * @param {string} bibtex - BibTeX source
12
+ * @returns {object[]} Array of CSL-JSON items
13
+ */
14
+
15
+ // ── LaTeX accent/command → Unicode mapping ──────────────────────────────────
16
+
17
+ const ACCENT_MAP = {
18
+ '`': { a: 'à', e: 'è', i: 'ì', o: 'ò', u: 'ù', A: 'À', E: 'È', I: 'Ì', O: 'Ò', U: 'Ù' },
19
+ "'": { a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', y: 'ý', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú', Y: 'Ý', c: 'ć', n: 'ń', s: 'ś', z: 'ź', C: 'Ć', N: 'Ń', S: 'Ś', Z: 'Ź' },
20
+ '"': { a: 'ä', e: 'ë', i: 'ï', o: 'ö', u: 'ü', y: 'ÿ', A: 'Ä', E: 'Ë', I: 'Ï', O: 'Ö', U: 'Ü', Y: 'Ÿ' },
21
+ '^': { a: 'â', e: 'ê', i: 'î', o: 'ô', u: 'û', A: 'Â', E: 'Ê', I: 'Î', O: 'Ô', U: 'Û' },
22
+ '~': { a: 'ã', n: 'ñ', o: 'õ', A: 'Ã', N: 'Ñ', O: 'Õ' },
23
+ '=': { a: 'ā', e: 'ē', i: 'ī', o: 'ō', u: 'ū', A: 'Ā', E: 'Ē', I: 'Ī', O: 'Ō', U: 'Ū' },
24
+ '.': { z: 'ż', Z: 'Ż', e: 'ė', I: 'İ' },
25
+ 'u': { a: 'ă', A: 'Ă', g: 'ğ', G: 'Ğ' },
26
+ 'v': { c: 'č', s: 'š', z: 'ž', r: 'ř', C: 'Č', S: 'Š', Z: 'Ž', R: 'Ř', e: 'ě', E: 'Ě', n: 'ň', N: 'Ň' },
27
+ 'c': { c: 'ç', C: 'Ç', s: 'ş', S: 'Ş', t: 'ţ', T: 'Ţ' },
28
+ 'H': { o: 'ő', u: 'ű', O: 'Ő', U: 'Ű' },
29
+ 'k': { a: 'ą', e: 'ę', A: 'Ą', E: 'Ę' },
30
+ 'd': { a: 'ạ', A: 'Ạ' },
31
+ }
32
+
33
+ const COMMAND_MAP = {
34
+ 'ss': 'ß', 'SS': 'SS',
35
+ 'o': 'ø', 'O': 'Ø',
36
+ 'ae': 'æ', 'AE': 'Æ',
37
+ 'oe': 'œ', 'OE': 'Œ',
38
+ 'aa': 'å', 'AA': 'Å',
39
+ 'l': 'ł', 'L': 'Ł',
40
+ 'i': 'ı', 'j': 'ȷ',
41
+ 'dh': 'ð', 'DH': 'Ð', 'dj': 'đ', 'DJ': 'Đ',
42
+ 'ng': 'ŋ', 'NG': 'Ŋ',
43
+ 'th': 'þ', 'TH': 'Þ',
44
+ 'textendash': '–', 'textemdash': '—',
45
+ 'textquotesingle': "'", 'textquotedblleft': '\u201C', 'textquotedblright': '\u201D',
46
+ 'textquoteleft': '\u2018', 'textquoteright': '\u2019',
47
+ 'textregistered': '®', 'textcopyright': '©', 'texttrademark': '™',
48
+ 'LaTeX': 'LaTeX', 'TeX': 'TeX', 'BibTeX': 'BibTeX',
49
+ }
50
+
51
+ const SYMBOL_ESCAPES = { '&': '&', '%': '%', '$': '$', '#': '#', '_': '_', '{': '{', '}': '}' }
52
+
53
+ const MONTH_MAP = {
54
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
55
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
56
+ }
57
+
58
+ // ── Entry type → CSL type mapping ───────────────────────────────────────────
59
+
60
+ const TYPE_MAP = {
61
+ article: 'article-journal',
62
+ book: 'book',
63
+ booklet: 'pamphlet',
64
+ conference: 'paper-conference',
65
+ inbook: 'chapter',
66
+ incollection: 'chapter',
67
+ inproceedings: 'paper-conference',
68
+ manual: 'report',
69
+ mastersthesis: 'thesis',
70
+ misc: 'article',
71
+ online: 'webpage',
72
+ phdthesis: 'thesis',
73
+ proceedings: 'book',
74
+ techreport: 'report',
75
+ unpublished: 'manuscript',
76
+ }
77
+
78
+ // ── LaTeX → Unicode conversion ──────────────────────────────────────────────
79
+
80
+ /**
81
+ * Convert LaTeX accents and commands to Unicode.
82
+ */
83
+ export function convertLatex(str) {
84
+ if (!str) return str
85
+
86
+ // Symbol escapes: \& \% \$ \# \_ \{ \}
87
+ str = str.replace(/\\([&%$#_{}])/g, (_, ch) => SYMBOL_ESCAPES[ch] || ch)
88
+
89
+ // Accented characters: \"{a} or \"a or \'{e} or \'e etc.
90
+ // Pattern: \accent{letter} or \accent letter
91
+ str = str.replace(/\\([`'"^~=.uvcHkd])\{([a-zA-Z])\}/g, (_, accent, letter) => {
92
+ return ACCENT_MAP[accent]?.[letter] ?? `${accent}${letter}`
93
+ })
94
+ str = str.replace(/\\([`'"^~])\{\\([ij])\}/g, (_, accent, letter) => {
95
+ const base = COMMAND_MAP[letter] || letter
96
+ return ACCENT_MAP[accent]?.[base] ?? `${accent}${base}`
97
+ })
98
+ str = str.replace(/\\([`'"^~])([a-zA-Z])/g, (_, accent, letter) => {
99
+ return ACCENT_MAP[accent]?.[letter] ?? `${accent}${letter}`
100
+ })
101
+ // Multi-char accent commands: \v{c}, \c{c}, \H{o}, \u{a}, \k{a}, \d{a}, \={a}, \.{z}
102
+ str = str.replace(/\\([uvcHkd=.])\{([a-zA-Z])\}/g, (_, accent, letter) => {
103
+ return ACCENT_MAP[accent]?.[letter] ?? `${accent}${letter}`
104
+ })
105
+
106
+ // Named commands: \ss, \o, \ae, etc.
107
+ // Match \command followed by {} or space or non-letter
108
+ str = str.replace(/\\([a-zA-Z]+)\{\}/g, (_, cmd) => COMMAND_MAP[cmd] ?? `\\${cmd}`)
109
+ str = str.replace(/\\([a-zA-Z]+)(?=[\s{},;.!?)]|$)/g, (_, cmd) => {
110
+ if (COMMAND_MAP[cmd] != null) return COMMAND_MAP[cmd]
111
+ // Skip unknown commands — leave as-is for round-trip safety
112
+ return `\\${cmd}`
113
+ })
114
+
115
+ // \url{...} → just the URL
116
+ str = str.replace(/\\url\{([^}]*)\}/g, '$1')
117
+
118
+ // \emph{...} or \textit{...} → just the text
119
+ str = str.replace(/\\(?:emph|textit|textbf|textsc|texttt)\{([^}]*)\}/g, '$1')
120
+
121
+ // Remove remaining braces (from {DNA} → DNA)
122
+ str = str.replace(/[{}]/g, '')
123
+
124
+ // Normalize whitespace (preserve non-breaking spaces from ~)
125
+ str = str.replace(/~/g, '\u00A0') // TeX non-breaking space
126
+ str = str.replace(/[\t\n\r ]+/g, ' ').trim()
127
+
128
+ return str
129
+ }
130
+
131
+ // ── BibTeX tokenizer/parser ─────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Parse BibTeX source into raw entries (before CSL conversion).
135
+ * Returns { type, key, fields } objects and populates string abbreviations.
136
+ */
137
+ function parseRawEntries(bibtex) {
138
+ const strings = { ...MONTH_MAP } // pre-populate month abbreviations
139
+ const entries = []
140
+ let pos = 0
141
+
142
+ function skipWhitespace() {
143
+ while (pos < bibtex.length && /\s/.test(bibtex[pos])) pos++
144
+ }
145
+
146
+ function readBraced() {
147
+ // pos is at opening {
148
+ let depth = 1
149
+ pos++ // skip {
150
+ let result = ''
151
+ while (pos < bibtex.length && depth > 0) {
152
+ if (bibtex[pos] === '{') { depth++; result += '{' }
153
+ else if (bibtex[pos] === '}') { depth--; if (depth > 0) result += '}' }
154
+ else if (bibtex[pos] === '\\') {
155
+ result += bibtex[pos]
156
+ pos++
157
+ if (pos < bibtex.length) result += bibtex[pos]
158
+ }
159
+ else result += bibtex[pos]
160
+ pos++
161
+ }
162
+ return result
163
+ }
164
+
165
+ function readQuoted() {
166
+ // pos is at opening "
167
+ pos++ // skip "
168
+ let result = ''
169
+ while (pos < bibtex.length && bibtex[pos] !== '"') {
170
+ if (bibtex[pos] === '{') {
171
+ result += '{' + readBraced() + '}'
172
+ } else if (bibtex[pos] === '\\') {
173
+ result += bibtex[pos]
174
+ pos++
175
+ if (pos < bibtex.length) result += bibtex[pos]
176
+ pos++
177
+ } else {
178
+ result += bibtex[pos]
179
+ pos++
180
+ }
181
+ }
182
+ if (pos < bibtex.length) pos++ // skip closing "
183
+ return result
184
+ }
185
+
186
+ function readValue() {
187
+ skipWhitespace()
188
+ let value = ''
189
+
190
+ while (pos < bibtex.length) {
191
+ skipWhitespace()
192
+ let part
193
+ if (bibtex[pos] === '{') {
194
+ part = readBraced()
195
+ } else if (bibtex[pos] === '"') {
196
+ part = readQuoted()
197
+ } else {
198
+ // Bare identifier or number
199
+ let ident = ''
200
+ while (pos < bibtex.length && /[a-zA-Z0-9_.-]/.test(bibtex[pos])) {
201
+ ident += bibtex[pos]
202
+ pos++
203
+ }
204
+ if (/^\d+$/.test(ident)) {
205
+ part = ident
206
+ } else {
207
+ // String reference
208
+ const key = ident.toLowerCase()
209
+ part = strings[key] != null ? String(strings[key]) : ident
210
+ }
211
+ }
212
+ value += part
213
+ skipWhitespace()
214
+ if (bibtex[pos] === '#') {
215
+ pos++ // skip #
216
+ continue
217
+ }
218
+ break
219
+ }
220
+ return value
221
+ }
222
+
223
+ function readEntryBody(delimiter) {
224
+ const close = delimiter === '{' ? '}' : ')'
225
+ skipWhitespace()
226
+
227
+ // Read citation key
228
+ let key = ''
229
+ while (pos < bibtex.length && bibtex[pos] !== ',' && bibtex[pos] !== close && !(/\s/.test(bibtex[pos]))) {
230
+ key += bibtex[pos]
231
+ pos++
232
+ }
233
+ key = key.trim()
234
+ skipWhitespace()
235
+ if (bibtex[pos] === ',') pos++
236
+
237
+ // Read fields
238
+ const fields = {}
239
+ while (pos < bibtex.length && bibtex[pos] !== close) {
240
+ skipWhitespace()
241
+ if (bibtex[pos] === close) break
242
+
243
+ // Read field name
244
+ let fieldName = ''
245
+ while (pos < bibtex.length && bibtex[pos] !== '=' && bibtex[pos] !== close && !(/\s/.test(bibtex[pos]))) {
246
+ fieldName += bibtex[pos]
247
+ pos++
248
+ }
249
+ fieldName = fieldName.trim().toLowerCase()
250
+ if (!fieldName || bibtex[pos] === close) break
251
+
252
+ skipWhitespace()
253
+ if (bibtex[pos] !== '=') break
254
+ pos++ // skip =
255
+
256
+ const value = readValue()
257
+ fields[fieldName] = value
258
+
259
+ skipWhitespace()
260
+ if (bibtex[pos] === ',') pos++
261
+ }
262
+
263
+ if (bibtex[pos] === close) pos++
264
+
265
+ return { key, fields }
266
+ }
267
+
268
+ // Main parse loop
269
+ while (pos < bibtex.length) {
270
+ // Find next @
271
+ const atIdx = bibtex.indexOf('@', pos)
272
+ if (atIdx === -1) break
273
+ pos = atIdx + 1
274
+
275
+ // Read entry type
276
+ skipWhitespace()
277
+ let entryType = ''
278
+ while (pos < bibtex.length && /[a-zA-Z]/.test(bibtex[pos])) {
279
+ entryType += bibtex[pos]
280
+ pos++
281
+ }
282
+ entryType = entryType.toLowerCase()
283
+
284
+ skipWhitespace()
285
+ const delimiter = bibtex[pos]
286
+ if (delimiter !== '{' && delimiter !== '(') continue
287
+ pos++ // skip { or (
288
+ const close = delimiter === '{' ? '}' : ')'
289
+
290
+ if (entryType === 'comment') {
291
+ // Skip to matching close
292
+ let depth = 1
293
+ while (pos < bibtex.length && depth > 0) {
294
+ if (bibtex[pos] === delimiter) depth++
295
+ else if (bibtex[pos] === close) depth--
296
+ pos++
297
+ }
298
+ continue
299
+ }
300
+
301
+ if (entryType === 'preamble') {
302
+ readValue()
303
+ skipWhitespace()
304
+ if (bibtex[pos] === close) pos++
305
+ continue
306
+ }
307
+
308
+ if (entryType === 'string') {
309
+ // @string{name = value}
310
+ skipWhitespace()
311
+ let name = ''
312
+ while (pos < bibtex.length && bibtex[pos] !== '=' && !(/\s/.test(bibtex[pos]))) {
313
+ name += bibtex[pos]
314
+ pos++
315
+ }
316
+ name = name.trim().toLowerCase()
317
+ skipWhitespace()
318
+ if (bibtex[pos] === '=') pos++
319
+ const value = readValue()
320
+ strings[name] = value
321
+ skipWhitespace()
322
+ if (bibtex[pos] === close) pos++
323
+ continue
324
+ }
325
+
326
+ // Regular entry
327
+ const { key, fields } = readEntryBody(delimiter)
328
+ entries.push({ type: entryType, key, fields })
329
+ }
330
+
331
+ return { entries, strings }
332
+ }
333
+
334
+ // ── BibTeX name parsing ─────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Parse a BibTeX name string into CSL name objects.
338
+ * Handles "Last, First" and "First Last" formats.
339
+ * Corporate authors in double braces: {World Health Organization}
340
+ */
341
+ function parseNames(nameStr) {
342
+ if (!nameStr) return []
343
+
344
+ // Split on " and " (case-insensitive, respecting braces)
345
+ const nameList = splitOnAnd(nameStr)
346
+
347
+ return nameList.map(name => {
348
+ name = name.trim()
349
+ if (!name) return null
350
+
351
+ // Corporate author: starts and ends with braces
352
+ if (name.startsWith('{') && name.endsWith('}')) {
353
+ return { literal: convertLatex(name.slice(1, -1)) }
354
+ }
355
+
356
+ // Convert LaTeX in the name
357
+ name = convertLatex(name)
358
+
359
+ // Split on commas (respecting braces)
360
+ const parts = splitRespectingBraces(name, ',').map(s => s.trim())
361
+
362
+ if (parts.length >= 3) {
363
+ // "von Last, Jr., First"
364
+ const { particle, family } = extractParticle(parts[0])
365
+ return clean({
366
+ family,
367
+ given: parts[2],
368
+ suffix: parts[1],
369
+ 'non-dropping-particle': particle,
370
+ })
371
+ } else if (parts.length === 2) {
372
+ // "von Last, First"
373
+ const { particle, family } = extractParticle(parts[0])
374
+ return clean({
375
+ family,
376
+ given: parts[1],
377
+ 'non-dropping-particle': particle,
378
+ })
379
+ } else {
380
+ // "First von Last" format
381
+ return parseFirstLast(name)
382
+ }
383
+ }).filter(Boolean)
384
+ }
385
+
386
+ function splitOnAnd(str) {
387
+ const parts = []
388
+ let depth = 0
389
+ let current = ''
390
+
391
+ for (let i = 0; i < str.length; i++) {
392
+ if (str[i] === '{') depth++
393
+ else if (str[i] === '}') depth--
394
+
395
+ if (depth === 0 && str.slice(i, i + 5).toLowerCase() === ' and ') {
396
+ parts.push(current)
397
+ current = ''
398
+ i += 4
399
+ continue
400
+ }
401
+ current += str[i]
402
+ }
403
+ parts.push(current)
404
+ return parts
405
+ }
406
+
407
+ function splitRespectingBraces(str, sep) {
408
+ const parts = []
409
+ let depth = 0
410
+ let current = ''
411
+ for (const ch of str) {
412
+ if (ch === '{') depth++
413
+ else if (ch === '}') depth--
414
+ if (ch === sep && depth === 0) {
415
+ parts.push(current)
416
+ current = ''
417
+ } else {
418
+ current += ch
419
+ }
420
+ }
421
+ parts.push(current)
422
+ return parts
423
+ }
424
+
425
+ function extractParticle(str) {
426
+ const words = str.trim().split(/\s+/)
427
+ if (words.length <= 1) return { particle: null, family: str.trim() }
428
+
429
+ // Find where particle (lowercase) ends and family (uppercase) starts
430
+ let particleEnd = 0
431
+ for (let i = 0; i < words.length - 1; i++) {
432
+ if (words[i][0] && words[i][0] === words[i][0].toLowerCase() && /[a-z]/.test(words[i][0])) {
433
+ particleEnd = i + 1
434
+ } else {
435
+ break
436
+ }
437
+ }
438
+
439
+ if (particleEnd === 0) {
440
+ return { particle: null, family: str.trim() }
441
+ }
442
+ return {
443
+ particle: words.slice(0, particleEnd).join(' '),
444
+ family: words.slice(particleEnd).join(' '),
445
+ }
446
+ }
447
+
448
+ function parseFirstLast(name) {
449
+ const words = name.trim().split(/\s+/)
450
+ if (words.length === 1) return { family: words[0] }
451
+
452
+ // Find the first lowercase word — that starts the "von" particle
453
+ // Everything before it is "First", everything from there to the last uppercase is "von Last"
454
+ let vonStart = -1
455
+ for (let i = 1; i < words.length - 1; i++) {
456
+ if (words[i][0] && words[i][0] === words[i][0].toLowerCase() && /[a-z]/.test(words[i][0])) {
457
+ vonStart = i
458
+ break
459
+ }
460
+ }
461
+
462
+ if (vonStart === -1) {
463
+ // No particle: "First Middle Last"
464
+ return {
465
+ given: words.slice(0, -1).join(' '),
466
+ family: words[words.length - 1],
467
+ }
468
+ }
469
+
470
+ // Find where family starts (last uppercase word sequence)
471
+ let familyStart = words.length - 1
472
+ for (let i = words.length - 2; i >= vonStart; i--) {
473
+ if (words[i][0] && words[i][0] === words[i][0].toUpperCase()) {
474
+ break
475
+ }
476
+ familyStart = i
477
+ }
478
+
479
+ const given = words.slice(0, vonStart).join(' ') || undefined
480
+ const particle = words.slice(vonStart, familyStart).join(' ') || undefined
481
+ const family = words.slice(familyStart).join(' ')
482
+
483
+ return clean({
484
+ given,
485
+ family,
486
+ 'non-dropping-particle': particle,
487
+ })
488
+ }
489
+
490
+ function clean(obj) {
491
+ const result = {}
492
+ for (const [k, v] of Object.entries(obj)) {
493
+ if (v != null && v !== '') result[k] = v
494
+ }
495
+ return result
496
+ }
497
+
498
+ // ── BibTeX → CSL-JSON field mapping ─────────────────────────────────────────
499
+
500
+ function convertToCSL(entry) {
501
+ const { type, key, fields } = entry
502
+ const item = {
503
+ id: key,
504
+ type: TYPE_MAP[type] || 'article',
505
+ }
506
+
507
+ // Genre for thesis types
508
+ if (type === 'phdthesis') item.genre = 'PhD thesis'
509
+ else if (type === 'mastersthesis') item.genre = "Master's thesis"
510
+
511
+ // Names
512
+ if (fields.author) item.author = parseNames(fields.author)
513
+ if (fields.editor) item.editor = parseNames(fields.editor)
514
+ if (fields.translator) item.translator = parseNames(fields.translator)
515
+
516
+ // Title
517
+ if (fields.title) item.title = convertLatex(fields.title)
518
+
519
+ // Container title depends on entry type
520
+ if (fields.journal) item['container-title'] = convertLatex(fields.journal)
521
+ else if (fields.booktitle) item['container-title'] = convertLatex(fields.booktitle)
522
+
523
+ // Date
524
+ const year = fields.year ? parseInt(fields.year, 10) : null
525
+ const month = fields.month != null ? parseMonth(fields.month) : null
526
+ if (year) {
527
+ const dateParts = [year]
528
+ if (month) dateParts.push(month)
529
+ item.issued = { 'date-parts': [dateParts] }
530
+ }
531
+
532
+ // Simple string fields
533
+ const stringFields = {
534
+ volume: 'volume',
535
+ number: 'issue',
536
+ pages: 'page',
537
+ doi: 'DOI',
538
+ url: 'URL',
539
+ isbn: 'ISBN',
540
+ issn: 'ISSN',
541
+ publisher: 'publisher',
542
+ address: 'publisher-place',
543
+ edition: 'edition',
544
+ series: 'collection-title',
545
+ note: 'note',
546
+ abstract: 'abstract',
547
+ keywords: 'keyword',
548
+ language: 'language',
549
+ chapter: 'chapter-number',
550
+ }
551
+
552
+ for (const [bibField, cslField] of Object.entries(stringFields)) {
553
+ if (fields[bibField]) {
554
+ let val = convertLatex(fields[bibField])
555
+ // Normalize page ranges: -- → –
556
+ if (cslField === 'page') val = val.replace(/--/g, '–')
557
+ item[cslField] = val
558
+ }
559
+ }
560
+
561
+ // Publisher from school/institution for thesis/report
562
+ if (!item.publisher) {
563
+ if (fields.school) item.publisher = convertLatex(fields.school)
564
+ else if (fields.institution) item.publisher = convertLatex(fields.institution)
565
+ }
566
+
567
+ // howpublished → URL if it looks like a URL
568
+ if (fields.howpublished && !item.URL) {
569
+ const hp = convertLatex(fields.howpublished)
570
+ if (/^https?:\/\//i.test(hp)) item.URL = hp
571
+ }
572
+
573
+ return item
574
+ }
575
+
576
+ function parseMonth(val) {
577
+ if (typeof val === 'number') return val
578
+ const str = String(val).trim().toLowerCase()
579
+ // Direct month number
580
+ const num = parseInt(str, 10)
581
+ if (!isNaN(num) && num >= 1 && num <= 12) return num
582
+ // 3-letter abbreviation
583
+ return MONTH_MAP[str.slice(0, 3)] || null
584
+ }
585
+
586
+ // ── Public API ──────────────────────────────────────────────────────────────
587
+
588
+ export function parseBibtex(bibtex) {
589
+ if (!bibtex || typeof bibtex !== 'string') return []
590
+ const { entries } = parseRawEntries(bibtex)
591
+ return entries.map(entry => convertToCSL(entry))
592
+ }
package/types.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { parseBibtex, convertLatex, exportBibtex } from '@citestyle/types'