@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 +21 -0
- package/README.md +115 -0
- package/package.json +31 -0
- package/src/export.js +185 -0
- package/src/index.js +8 -0
- package/src/parse.js +592 -0
- package/types.d.ts +1 -0
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
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'
|