@citestyle/registry 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 +123 -0
- package/package.json +32 -0
- package/src/index.js +13 -0
- package/src/registry.js +668 -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,123 @@
|
|
|
1
|
+
# @citestyle/registry
|
|
2
|
+
|
|
3
|
+
Citation registry that manages cross-citation state for a compiled CSL style. Handles citation numbering, bibliography sorting, year-suffix disambiguation, name disambiguation, cite collapsing, and subsequent-author-substitute.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @citestyle/registry
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { createRegistry } from '@citestyle/registry'
|
|
15
|
+
import * as apa from '@citestyle/styles/apa'
|
|
16
|
+
|
|
17
|
+
// Create a registry bound to a compiled style
|
|
18
|
+
const registry = createRegistry(apa)
|
|
19
|
+
|
|
20
|
+
// Add items (assigns citation numbers, triggers disambiguation)
|
|
21
|
+
registry.addItems([
|
|
22
|
+
{
|
|
23
|
+
id: 'smith2024',
|
|
24
|
+
type: 'article-journal',
|
|
25
|
+
title: 'Machine learning in practice',
|
|
26
|
+
author: [{ family: 'Smith', given: 'Jane' }],
|
|
27
|
+
issued: { 'date-parts': [[2024]] },
|
|
28
|
+
'container-title': 'Nature',
|
|
29
|
+
volume: '620',
|
|
30
|
+
page: '100-108',
|
|
31
|
+
DOI: '10.1038/example',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'doe2023',
|
|
35
|
+
type: 'book',
|
|
36
|
+
title: 'Statistical Methods',
|
|
37
|
+
author: [{ family: 'Doe', given: 'John' }],
|
|
38
|
+
issued: { 'date-parts': [[2023]] },
|
|
39
|
+
publisher: 'Academic Press',
|
|
40
|
+
},
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
// Format an inline citation
|
|
44
|
+
const citation = registry.cite([{ id: 'smith2024' }])
|
|
45
|
+
console.log(citation.text) // (Smith, 2024)
|
|
46
|
+
console.log(citation.html) // <span class="csl-citation">(Smith, 2024)</span>
|
|
47
|
+
|
|
48
|
+
// Citation with locator
|
|
49
|
+
const withPage = registry.cite([
|
|
50
|
+
{ id: 'smith2024', locator: '105', label: 'page' },
|
|
51
|
+
])
|
|
52
|
+
console.log(withPage.text) // (Smith, 2024, p. 105)
|
|
53
|
+
|
|
54
|
+
// Multi-cite
|
|
55
|
+
const multi = registry.cite([
|
|
56
|
+
{ id: 'smith2024' },
|
|
57
|
+
{ id: 'doe2023' },
|
|
58
|
+
])
|
|
59
|
+
console.log(multi.text) // (Doe, 2023; Smith, 2024)
|
|
60
|
+
|
|
61
|
+
// Get the full bibliography (sorted per style rules)
|
|
62
|
+
const entries = registry.getBibliography()
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
console.log(entry.text) // Plain text
|
|
65
|
+
console.log(entry.html) // Semantic HTML with CSS classes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check registry size
|
|
69
|
+
console.log(registry.size) // 2
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
### `createRegistry(style, options?)`
|
|
75
|
+
|
|
76
|
+
Create a new registry for a compiled CSL style.
|
|
77
|
+
|
|
78
|
+
- `style` -- A compiled style module (must export `bibliography` or `citation`)
|
|
79
|
+
- `options.subsequentAuthorSubstitute` (string) -- Override the style's substitute string (e.g., `"---"`)
|
|
80
|
+
|
|
81
|
+
Returns a `Registry` object.
|
|
82
|
+
|
|
83
|
+
### Registry Methods
|
|
84
|
+
|
|
85
|
+
#### `addItems(items)`
|
|
86
|
+
|
|
87
|
+
Add CSL-JSON items to the registry. Items are stored by `id` and assigned citation numbers in insertion order. Duplicate IDs update the existing item.
|
|
88
|
+
|
|
89
|
+
#### `getItem(id)`
|
|
90
|
+
|
|
91
|
+
Look up a single item by ID. Returns `undefined` if not found.
|
|
92
|
+
|
|
93
|
+
#### `cite(cites, ctx?)`
|
|
94
|
+
|
|
95
|
+
Format a citation cluster. Each cite object can include:
|
|
96
|
+
|
|
97
|
+
| Property | Type | Description |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `id` | string | Item ID (resolved from registry) |
|
|
100
|
+
| `item` | CslItem | Pre-resolved item (bypasses lookup) |
|
|
101
|
+
| `locator` | string | Locator value (e.g., page number) |
|
|
102
|
+
| `label` | string | Locator label (`'page'`, `'chapter'`, etc.) |
|
|
103
|
+
| `prefix` | string | Text before the citation |
|
|
104
|
+
| `suffix` | string | Text after the citation |
|
|
105
|
+
|
|
106
|
+
Returns `{ text: string, html: string }`.
|
|
107
|
+
|
|
108
|
+
#### `getBibliography(ctx?)`
|
|
109
|
+
|
|
110
|
+
Get the formatted bibliography. Items are sorted per the style's sort rules. Returns an array of `FormattedEntry` objects, each with `{ text, html, parts, links }`.
|
|
111
|
+
|
|
112
|
+
#### `size`
|
|
113
|
+
|
|
114
|
+
Read-only property returning the number of items in the registry.
|
|
115
|
+
|
|
116
|
+
## Features
|
|
117
|
+
|
|
118
|
+
- **Citation numbering**: Numeric styles (IEEE, Vancouver) get auto-assigned numbers
|
|
119
|
+
- **Bibliography sorting**: Uses the style's compiled sort comparator
|
|
120
|
+
- **Year-suffix**: Detects author+year collisions and assigns a/b/c suffixes
|
|
121
|
+
- **Name disambiguation**: `disambiguate-add-givenname` (5 rules) and `disambiguate-add-names`
|
|
122
|
+
- **Cite collapsing**: Numeric ranges (`[1-3, 5]`) and author-date grouping (`Smith, 2020, 2021`)
|
|
123
|
+
- **Subsequent-author-substitute**: Replaces repeated authors in bibliography (e.g., with em-dash)
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@citestyle/registry",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Citation registry: year-suffixes, numbering, sorting (~5-8KB)",
|
|
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/core": "0.0.2",
|
|
19
|
+
"@citestyle/types": "0.0.2"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"vitest": "^3.1.1"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.19.0"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "vitest run --passWithNoTests",
|
|
30
|
+
"lint": "echo 'TODO: configure linter'"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @citestyle/registry
|
|
3
|
+
*
|
|
4
|
+
* Runtime citation state manager. Tracks citations across a document
|
|
5
|
+
* for features that require cross-reference knowledge:
|
|
6
|
+
*
|
|
7
|
+
* - Year-suffix assignment (2024a, 2024b)
|
|
8
|
+
* - Citation number tracking (numeric styles)
|
|
9
|
+
* - Bibliography sorting (using compiled comparator)
|
|
10
|
+
* - Subsequent-author-substitute
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { createRegistry } from './registry.js'
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation registry — tracks cross-citation state for a compiled style.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Citation-number assignment (for numeric styles like IEEE, Vancouver)
|
|
6
|
+
* - Bibliography sorting (using compiled sort comparator)
|
|
7
|
+
* - Subsequent-author-substitute (em-dash for repeated authors in bibliography)
|
|
8
|
+
* - Year-suffix disambiguation (a, b, c for same author+year)
|
|
9
|
+
* - Name disambiguation (add-givenname, add-names)
|
|
10
|
+
* - Cite collapsing (numeric ranges, author-date year grouping)
|
|
11
|
+
* - Item lookup by ID
|
|
12
|
+
*
|
|
13
|
+
* @param {import('@citestyle/types').CompiledStyle} style - A compiled CSL style module
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {string} [options.subsequentAuthorSubstitute] - Override style's substitute string
|
|
16
|
+
* @returns {import('@citestyle/types').Registry}
|
|
17
|
+
*/
|
|
18
|
+
export function createRegistry(style, options = {}) {
|
|
19
|
+
if (!style || (!style.bibliography && !style.citation)) {
|
|
20
|
+
throw new Error('createRegistry requires a compiled CSL style with bibliography() or citation()')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, import('@citestyle/types').CslItem>} */
|
|
24
|
+
const itemsById = new Map()
|
|
25
|
+
|
|
26
|
+
/** @type {string[]} - Item IDs in insertion order */
|
|
27
|
+
const itemOrder = []
|
|
28
|
+
|
|
29
|
+
/** @type {Map<string, number>} - citation-number assignments (1-based) */
|
|
30
|
+
const citationNumbers = new Map()
|
|
31
|
+
|
|
32
|
+
/** Next citation number to assign */
|
|
33
|
+
let nextCitNum = 1
|
|
34
|
+
|
|
35
|
+
/** @type {Map<string, object>} - Per-item disambiguation state */
|
|
36
|
+
const disambigState = new Map()
|
|
37
|
+
|
|
38
|
+
/** Whether disambiguation has been computed */
|
|
39
|
+
let disambigDirty = true
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add items to the registry.
|
|
43
|
+
* Items are stored by ID and assigned citation numbers in insertion order.
|
|
44
|
+
*
|
|
45
|
+
* @param {import('@citestyle/types').CslItem[]} items
|
|
46
|
+
*/
|
|
47
|
+
function addItems(items) {
|
|
48
|
+
for (const item of items) {
|
|
49
|
+
const id = String(item.id)
|
|
50
|
+
if (!itemsById.has(id)) {
|
|
51
|
+
itemsById.set(id, item)
|
|
52
|
+
itemOrder.push(id)
|
|
53
|
+
citationNumbers.set(id, nextCitNum++)
|
|
54
|
+
// Set citation-number on the item for numeric styles
|
|
55
|
+
item['citation-number'] = citationNumbers.get(id)
|
|
56
|
+
disambigDirty = true
|
|
57
|
+
} else {
|
|
58
|
+
// Update existing item
|
|
59
|
+
itemsById.set(id, item)
|
|
60
|
+
item['citation-number'] = citationNumbers.get(id)
|
|
61
|
+
disambigDirty = true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get an item by ID.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} id
|
|
70
|
+
* @returns {import('@citestyle/types').CslItem | undefined}
|
|
71
|
+
*/
|
|
72
|
+
function getItem(id) {
|
|
73
|
+
return itemsById.get(String(id))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Ensure disambiguation and year-suffixes are computed.
|
|
78
|
+
*/
|
|
79
|
+
function ensureDisambiguated() {
|
|
80
|
+
if (!disambigDirty) return
|
|
81
|
+
disambigDirty = false
|
|
82
|
+
runDisambiguation()
|
|
83
|
+
assignYearSuffixes()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format a citation cluster.
|
|
88
|
+
* Resolves item IDs to items and delegates to the compiled style's citation().
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<{id: string, item?: object, locator?: string, label?: string, prefix?: string, suffix?: string}>} cites
|
|
91
|
+
* @param {object} [ctx] - Format context
|
|
92
|
+
* @returns {import('@citestyle/types').FormattedCitation}
|
|
93
|
+
*/
|
|
94
|
+
function cite(cites, ctx = {}) {
|
|
95
|
+
if (!style.citation) {
|
|
96
|
+
return { text: '', html: '' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ensureDisambiguated()
|
|
100
|
+
|
|
101
|
+
const resolvedCites = cites.map(c => {
|
|
102
|
+
const item = c.item || itemsById.get(String(c.id))
|
|
103
|
+
if (!item) {
|
|
104
|
+
return { item: { id: c.id, type: 'article', title: '[missing]' } }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Ensure citation-number is set
|
|
108
|
+
if (!citationNumbers.has(String(item.id))) {
|
|
109
|
+
addItems([item])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const citeObj = { item }
|
|
113
|
+
if (c.locator != null) citeObj.locator = c.locator
|
|
114
|
+
if (c.label != null) citeObj.label = c.label
|
|
115
|
+
if (c.prefix != null) citeObj.prefix = c.prefix
|
|
116
|
+
if (c.suffix != null) citeObj.suffix = c.suffix
|
|
117
|
+
|
|
118
|
+
// Attach per-item disambiguation overrides
|
|
119
|
+
const disambig = disambigState.get(String(item.id))
|
|
120
|
+
if (disambig) citeObj._disambig = disambig
|
|
121
|
+
|
|
122
|
+
return citeObj
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Apply cite collapsing
|
|
126
|
+
const collapse = style.meta?.collapse
|
|
127
|
+
if (collapse === 'citation-number' && resolvedCites.length > 1) {
|
|
128
|
+
return collapseNumericCitation(resolvedCites, ctx)
|
|
129
|
+
}
|
|
130
|
+
if ((collapse === 'year' || collapse === 'year-suffix' || collapse === 'year-suffix-ranged') && resolvedCites.length > 1) {
|
|
131
|
+
return collapseAuthorDateCitation(resolvedCites, ctx, collapse)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return style.citation(resolvedCites, ctx)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Numeric cite collapsing ──────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Collapse numeric citations into ranges.
|
|
141
|
+
* [1, 2, 3, 5] → [1–3, 5]
|
|
142
|
+
*/
|
|
143
|
+
function collapseNumericCitation(resolvedCites, ctx) {
|
|
144
|
+
const sorted = [...resolvedCites].sort((a, b) =>
|
|
145
|
+
(a.item['citation-number'] || 0) - (b.item['citation-number'] || 0)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const numbers = sorted.map(c => c.item['citation-number'] || 0)
|
|
149
|
+
const hasLocators = sorted.some(c => c.locator != null)
|
|
150
|
+
|
|
151
|
+
if (hasLocators) {
|
|
152
|
+
return style.citation(sorted, ctx)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ranges = []
|
|
156
|
+
let rangeStart = numbers[0]
|
|
157
|
+
let rangeEnd = numbers[0]
|
|
158
|
+
|
|
159
|
+
for (let i = 1; i < numbers.length; i++) {
|
|
160
|
+
if (numbers[i] === rangeEnd + 1) {
|
|
161
|
+
rangeEnd = numbers[i]
|
|
162
|
+
} else {
|
|
163
|
+
ranges.push([rangeStart, rangeEnd])
|
|
164
|
+
rangeStart = numbers[i]
|
|
165
|
+
rangeEnd = numbers[i]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
ranges.push([rangeStart, rangeEnd])
|
|
169
|
+
|
|
170
|
+
const hasCollapsible = ranges.some(([s, e]) => e - s >= 2)
|
|
171
|
+
if (!hasCollapsible) {
|
|
172
|
+
return style.citation(sorted, ctx)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const singleResult = style.citation([sorted[0]], ctx)
|
|
176
|
+
const singleNum = String(numbers[0])
|
|
177
|
+
|
|
178
|
+
const textIdx = singleResult.text.indexOf(singleNum)
|
|
179
|
+
const textPrefix = textIdx >= 0 ? singleResult.text.slice(0, textIdx) : ''
|
|
180
|
+
const textSuffix = textIdx >= 0 ? singleResult.text.slice(textIdx + singleNum.length) : ''
|
|
181
|
+
|
|
182
|
+
const delimiter = style.meta?.citationLayoutDelimiter || ', '
|
|
183
|
+
const rangeStrs = ranges.map(([s, e]) => {
|
|
184
|
+
if (s === e) return String(s)
|
|
185
|
+
if (e - s === 1) return `${s}${delimiter}${e}`
|
|
186
|
+
return `${s}\u2013${e}`
|
|
187
|
+
})
|
|
188
|
+
const collapsedText = textPrefix + rangeStrs.join(delimiter) + textSuffix
|
|
189
|
+
|
|
190
|
+
const htmlPrefix = singleResult.html.includes('<sup>')
|
|
191
|
+
? '<span class="csl-citation"><sup>' : '<span class="csl-citation">'
|
|
192
|
+
const htmlSuffix = singleResult.html.includes('</sup>')
|
|
193
|
+
? '</sup></span>' : '</span>'
|
|
194
|
+
const innerHtml = singleResult.html
|
|
195
|
+
.replace(/<span class="csl-citation">/, '')
|
|
196
|
+
.replace(/<\/span>$/, '')
|
|
197
|
+
.replace(/<sup>/, '')
|
|
198
|
+
.replace(/<\/sup>/, '')
|
|
199
|
+
const htmlNumIdx = innerHtml.indexOf(singleNum)
|
|
200
|
+
const htmlInnerPrefix = htmlNumIdx >= 0 ? innerHtml.slice(0, htmlNumIdx) : ''
|
|
201
|
+
const htmlInnerSuffix = htmlNumIdx >= 0 ? innerHtml.slice(htmlNumIdx + singleNum.length) : ''
|
|
202
|
+
|
|
203
|
+
const collapsedHtml = htmlPrefix + htmlInnerPrefix +
|
|
204
|
+
rangeStrs.join(delimiter) + htmlInnerSuffix + htmlSuffix
|
|
205
|
+
|
|
206
|
+
return { text: collapsedText, html: collapsedHtml }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Author-date cite collapsing ──────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Collapse author-date citations by grouping consecutive same-author cites.
|
|
213
|
+
*
|
|
214
|
+
* collapse="year": (Smith, 2020, 2021; Jones, 2019)
|
|
215
|
+
* collapse="year-suffix": (Smith, 2020a, b; Jones, 2019)
|
|
216
|
+
*/
|
|
217
|
+
function collapseAuthorDateCitation(resolvedCites, ctx, collapseMode) {
|
|
218
|
+
const layoutDelim = style.meta?.citationLayoutDelimiter || '; '
|
|
219
|
+
const layoutPrefix = style.meta?.citationLayoutPrefix || ''
|
|
220
|
+
const layoutSuffix = style.meta?.citationLayoutSuffix || ''
|
|
221
|
+
const citeGroupDelim = style.meta?.citeGroupDelimiter || ', '
|
|
222
|
+
const yearSuffixDelim = style.meta?.yearSuffixDelimiter || citeGroupDelim
|
|
223
|
+
const afterCollapseDelim = style.meta?.afterCollapseDelimiter || layoutDelim
|
|
224
|
+
|
|
225
|
+
// Render each cite individually
|
|
226
|
+
const citeData = resolvedCites.map(cite => {
|
|
227
|
+
const result = style.citation([cite], ctx)
|
|
228
|
+
// Strip layout prefix/suffix to get inner content
|
|
229
|
+
let inner = result.text
|
|
230
|
+
if (layoutPrefix && inner.startsWith(layoutPrefix)) inner = inner.slice(layoutPrefix.length)
|
|
231
|
+
if (layoutSuffix && inner.endsWith(layoutSuffix)) inner = inner.slice(0, -layoutSuffix.length)
|
|
232
|
+
|
|
233
|
+
const year = String(cite.item.issued?.['date-parts']?.[0]?.[0] || '')
|
|
234
|
+
const yearSuffix = cite.item['year-suffix'] || ''
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
cite,
|
|
238
|
+
inner,
|
|
239
|
+
authorKey: authorOnlyKey(cite.item),
|
|
240
|
+
year,
|
|
241
|
+
yearSuffix,
|
|
242
|
+
yearStr: year + yearSuffix,
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// Group consecutive cites by author
|
|
247
|
+
const authorGroups = []
|
|
248
|
+
let currentGroup = [citeData[0]]
|
|
249
|
+
for (let i = 1; i < citeData.length; i++) {
|
|
250
|
+
if (citeData[i].authorKey && citeData[i].authorKey === currentGroup[0].authorKey) {
|
|
251
|
+
currentGroup.push(citeData[i])
|
|
252
|
+
} else {
|
|
253
|
+
authorGroups.push(currentGroup)
|
|
254
|
+
currentGroup = [citeData[i]]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
authorGroups.push(currentGroup)
|
|
258
|
+
|
|
259
|
+
// Check if any group has >1 cite (otherwise no collapsing needed)
|
|
260
|
+
const hasMultiCiteGroup = authorGroups.some(g => g.length > 1)
|
|
261
|
+
if (!hasMultiCiteGroup) {
|
|
262
|
+
return style.citation(resolvedCites, ctx)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Build collapsed output
|
|
266
|
+
const groupStrs = authorGroups.map(group => {
|
|
267
|
+
if (group.length === 1) return group[0].inner
|
|
268
|
+
|
|
269
|
+
const first = group[0]
|
|
270
|
+
// Extract the author portion from the first cite's inner text
|
|
271
|
+
// by finding where the year starts
|
|
272
|
+
const yearIdx = first.inner.lastIndexOf(first.yearStr)
|
|
273
|
+
if (yearIdx < 0) {
|
|
274
|
+
// Can't find year — fall back to no collapsing for this group
|
|
275
|
+
return group.map(c => c.inner).join(layoutDelim)
|
|
276
|
+
}
|
|
277
|
+
const authorPart = first.inner.slice(0, yearIdx)
|
|
278
|
+
|
|
279
|
+
if (collapseMode === 'year-suffix' || collapseMode === 'year-suffix-ranged') {
|
|
280
|
+
// Sub-group by year within this author group
|
|
281
|
+
const yearSubgroups = []
|
|
282
|
+
let currentYearGroup = [group[0]]
|
|
283
|
+
for (let i = 1; i < group.length; i++) {
|
|
284
|
+
if (group[i].year === currentYearGroup[0].year) {
|
|
285
|
+
currentYearGroup.push(group[i])
|
|
286
|
+
} else {
|
|
287
|
+
yearSubgroups.push(currentYearGroup)
|
|
288
|
+
currentYearGroup = [group[i]]
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
yearSubgroups.push(currentYearGroup)
|
|
292
|
+
|
|
293
|
+
const yearParts = yearSubgroups.map((yg, yi) => {
|
|
294
|
+
if (yg.length === 1) {
|
|
295
|
+
return yi === 0 ? yg[0].yearStr : yg[0].yearStr
|
|
296
|
+
}
|
|
297
|
+
// First cite shows full year+suffix, subsequent show suffix only
|
|
298
|
+
const firstYearStr = yg[0].yearStr
|
|
299
|
+
const suffixes = yg.slice(1).map(c => c.yearSuffix || c.yearStr)
|
|
300
|
+
return firstYearStr + yearSuffixDelim + suffixes.join(yearSuffixDelim)
|
|
301
|
+
})
|
|
302
|
+
return authorPart + yearParts.join(citeGroupDelim)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// collapse="year": show first cite fully, subsequent show year+suffix
|
|
306
|
+
const yearStrs = group.slice(1).map(c => c.yearStr)
|
|
307
|
+
return authorPart + first.yearStr + citeGroupDelim + yearStrs.join(citeGroupDelim)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const innerText = groupStrs.join(afterCollapseDelim)
|
|
311
|
+
const text = layoutPrefix + innerText + layoutSuffix
|
|
312
|
+
|
|
313
|
+
// Build HTML: wrap in citation span, convert inner content to HTML
|
|
314
|
+
const html = '<span class="csl-citation">' + escapeForHtml(text).replace(
|
|
315
|
+
escapeForHtml(layoutPrefix),
|
|
316
|
+
layoutPrefix ? escapeForHtml(layoutPrefix) : ''
|
|
317
|
+
) + '</span>'
|
|
318
|
+
|
|
319
|
+
return { text, html }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Name disambiguation ─────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run name disambiguation algorithm.
|
|
326
|
+
*
|
|
327
|
+
* 1. Render each item's citation with default settings
|
|
328
|
+
* 2. Detect collisions (identical citation text for different items)
|
|
329
|
+
* 3. Apply add-givenname (expand given names/initials)
|
|
330
|
+
* 4. Apply add-names (show more names before et-al)
|
|
331
|
+
* 5. Remaining collisions handled by year-suffix (separate step)
|
|
332
|
+
*/
|
|
333
|
+
function runDisambiguation() {
|
|
334
|
+
const addGivenname = style.meta?.disambiguateAddGivenname
|
|
335
|
+
const addNames = style.meta?.disambiguateAddNames
|
|
336
|
+
if (!addGivenname && !addNames) return
|
|
337
|
+
if (!style.citation) return
|
|
338
|
+
|
|
339
|
+
const rule = style.meta?.givennameDisambiguationRule || 'by-cite'
|
|
340
|
+
|
|
341
|
+
// Clear previous state
|
|
342
|
+
disambigState.clear()
|
|
343
|
+
|
|
344
|
+
// Render each item's citation to detect collisions
|
|
345
|
+
const renderings = new Map()
|
|
346
|
+
for (const id of itemOrder) {
|
|
347
|
+
const item = itemsById.get(id)
|
|
348
|
+
if (!item) continue
|
|
349
|
+
renderings.set(id, renderCiteSingle(id))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Group by identical rendering
|
|
353
|
+
const collisionGroups = groupByValue(renderings)
|
|
354
|
+
|
|
355
|
+
// Process collision groups
|
|
356
|
+
for (const ids of collisionGroups) {
|
|
357
|
+
if (ids.length <= 1) continue
|
|
358
|
+
|
|
359
|
+
// Step 1: Try add-givenname
|
|
360
|
+
if (addGivenname) {
|
|
361
|
+
applyGivennameDisambig(ids, rule)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Step 2: Re-check and try add-names for still-colliding items
|
|
365
|
+
if (addNames) {
|
|
366
|
+
const newRenderings = new Map()
|
|
367
|
+
for (const id of ids) {
|
|
368
|
+
newRenderings.set(id, renderCiteSingle(id))
|
|
369
|
+
}
|
|
370
|
+
const stillColliding = groupByValue(newRenderings)
|
|
371
|
+
for (const subIds of stillColliding) {
|
|
372
|
+
if (subIds.length <= 1) continue
|
|
373
|
+
applyAddNamesDisambig(subIds)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Render a single item's citation, applying any current disambiguation state.
|
|
381
|
+
*/
|
|
382
|
+
function renderCiteSingle(id) {
|
|
383
|
+
const item = itemsById.get(id)
|
|
384
|
+
const disambig = disambigState.get(id)
|
|
385
|
+
const cite = disambig ? { item, _disambig: disambig } : { item }
|
|
386
|
+
return style.citation([cite]).text
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Group a Map<id, value> by identical values.
|
|
391
|
+
* Returns arrays of ids that share the same value.
|
|
392
|
+
*/
|
|
393
|
+
function groupByValue(map) {
|
|
394
|
+
const groups = new Map()
|
|
395
|
+
for (const [id, text] of map) {
|
|
396
|
+
if (!groups.has(text)) groups.set(text, [])
|
|
397
|
+
groups.get(text).push(id)
|
|
398
|
+
}
|
|
399
|
+
return [...groups.values()]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Apply givenname disambiguation to a collision group.
|
|
404
|
+
*/
|
|
405
|
+
function applyGivennameDisambig(ids, rule) {
|
|
406
|
+
switch (rule) {
|
|
407
|
+
case 'primary-name':
|
|
408
|
+
for (const id of ids) {
|
|
409
|
+
disambigState.set(id, { ...disambigState.get(id), expandIndices: [0] })
|
|
410
|
+
}
|
|
411
|
+
break
|
|
412
|
+
case 'primary-name-with-initials':
|
|
413
|
+
for (const id of ids) {
|
|
414
|
+
disambigState.set(id, { ...disambigState.get(id), expandIndices: [0], withInitials: true })
|
|
415
|
+
}
|
|
416
|
+
break
|
|
417
|
+
case 'all-names':
|
|
418
|
+
for (const id of ids) {
|
|
419
|
+
disambigState.set(id, { ...disambigState.get(id), expandAll: true })
|
|
420
|
+
}
|
|
421
|
+
break
|
|
422
|
+
case 'all-names-with-initials':
|
|
423
|
+
for (const id of ids) {
|
|
424
|
+
disambigState.set(id, { ...disambigState.get(id), expandAll: true, withInitials: true })
|
|
425
|
+
}
|
|
426
|
+
break
|
|
427
|
+
case 'by-cite':
|
|
428
|
+
disambiguateProgressively(ids)
|
|
429
|
+
break
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Progressive disambiguation for by-cite rule.
|
|
435
|
+
* Expand names one position at a time until citations are unique.
|
|
436
|
+
*/
|
|
437
|
+
function disambiguateProgressively(ids) {
|
|
438
|
+
const items = ids.map(id => itemsById.get(id))
|
|
439
|
+
const maxNames = Math.max(...items.map(i => (i.author || i.editor || []).length))
|
|
440
|
+
|
|
441
|
+
for (let expandCount = 1; expandCount <= maxNames; expandCount++) {
|
|
442
|
+
const indices = Array.from({ length: expandCount }, (_, i) => i)
|
|
443
|
+
|
|
444
|
+
for (const id of ids) {
|
|
445
|
+
disambigState.set(id, { ...disambigState.get(id), expandIndices: indices })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check if resolved
|
|
449
|
+
const renderings = new Map()
|
|
450
|
+
for (const id of ids) {
|
|
451
|
+
renderings.set(id, renderCiteSingle(id))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const groups = groupByValue(renderings)
|
|
455
|
+
const stillColliding = groups.filter(g => g.length > 1)
|
|
456
|
+
if (stillColliding.length === 0) break
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Apply add-names disambiguation: increase et-al-use-first until
|
|
462
|
+
* citations are unique or all names are shown.
|
|
463
|
+
*/
|
|
464
|
+
function applyAddNamesDisambig(ids) {
|
|
465
|
+
const items = ids.map(id => itemsById.get(id))
|
|
466
|
+
const maxNames = Math.max(...items.map(i => (i.author || i.editor || []).length))
|
|
467
|
+
|
|
468
|
+
for (let n = 2; n <= maxNames; n++) {
|
|
469
|
+
for (const id of ids) {
|
|
470
|
+
const existing = disambigState.get(id) || {}
|
|
471
|
+
disambigState.set(id, { ...existing, etAlUseFirst: n, etAlMin: n + 1 })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const renderings = new Map()
|
|
475
|
+
for (const id of ids) {
|
|
476
|
+
renderings.set(id, renderCiteSingle(id))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const groups = groupByValue(renderings)
|
|
480
|
+
const stillColliding = groups.filter(g => g.length > 1)
|
|
481
|
+
if (stillColliding.length === 0) break
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Year-suffix disambiguation ──────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Assign year-suffix letters (a, b, c, ...) to items that share the same
|
|
489
|
+
* author+year combination. Sets `item['year-suffix']` on affected items.
|
|
490
|
+
* Only active when the style has `disambiguate-add-year-suffix="true"`.
|
|
491
|
+
*/
|
|
492
|
+
function assignYearSuffixes() {
|
|
493
|
+
const useYearSuffix = style.meta?.disambiguateAddYearSuffix === true
|
|
494
|
+
if (!useYearSuffix) return
|
|
495
|
+
|
|
496
|
+
// Group items by author-year key
|
|
497
|
+
const groups = new Map()
|
|
498
|
+
for (const id of itemOrder) {
|
|
499
|
+
const item = itemsById.get(id)
|
|
500
|
+
if (!item) continue
|
|
501
|
+
const key = authorYearKey(item)
|
|
502
|
+
if (!key) continue
|
|
503
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
504
|
+
groups.get(key).push(item)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Assign suffixes to groups with >1 item
|
|
508
|
+
for (const [, items] of groups) {
|
|
509
|
+
if (items.length <= 1) {
|
|
510
|
+
// Single item — clear any stale suffix
|
|
511
|
+
delete items[0]['year-suffix']
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
for (let i = 0; i < items.length; i++) {
|
|
515
|
+
items[i]['year-suffix'] = String.fromCharCode(97 + i) // a, b, c, ...
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Bibliography ────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Get the formatted bibliography.
|
|
524
|
+
* Sorts items using the style's sort comparator, applies subsequent-author-substitute,
|
|
525
|
+
* and returns FormattedEntry[] in bibliography order.
|
|
526
|
+
*
|
|
527
|
+
* @param {object} [ctx] - Format context
|
|
528
|
+
* @returns {import('@citestyle/types').FormattedEntry[]}
|
|
529
|
+
*/
|
|
530
|
+
function getBibliography(ctx = {}) {
|
|
531
|
+
if (!style.bibliography) return []
|
|
532
|
+
|
|
533
|
+
ensureDisambiguated()
|
|
534
|
+
|
|
535
|
+
// Collect all items
|
|
536
|
+
let items = itemOrder.map(id => itemsById.get(id)).filter(Boolean)
|
|
537
|
+
|
|
538
|
+
// Sort if the style provides a comparator
|
|
539
|
+
if (style.bibliographySort) {
|
|
540
|
+
items = [...items].sort(style.bibliographySort)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Format each item
|
|
544
|
+
const entries = items.map(item => style.bibliography(item, ctx))
|
|
545
|
+
|
|
546
|
+
// Apply subsequent-author-substitute if configured
|
|
547
|
+
const subst = options.subsequentAuthorSubstitute ??
|
|
548
|
+
style.meta?.subsequentAuthorSubstitute ?? null
|
|
549
|
+
if (subst != null) {
|
|
550
|
+
applySubsequentAuthorSubstitute(entries, items, subst)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return entries
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
addItems,
|
|
558
|
+
getItem,
|
|
559
|
+
cite,
|
|
560
|
+
getBibliography,
|
|
561
|
+
/** Number of items in the registry */
|
|
562
|
+
get size() { return itemsById.size },
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── Helper functions ──────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Apply subsequent-author-substitute to formatted bibliography entries.
|
|
570
|
+
*/
|
|
571
|
+
function applySubsequentAuthorSubstitute(entries, items, substitute) {
|
|
572
|
+
if (entries.length < 2) return
|
|
573
|
+
|
|
574
|
+
let prevAuthorKey = authorKey(items[0])
|
|
575
|
+
|
|
576
|
+
for (let i = 1; i < entries.length; i++) {
|
|
577
|
+
const key = authorKey(items[i])
|
|
578
|
+
if (key && key === prevAuthorKey) {
|
|
579
|
+
entries[i] = {
|
|
580
|
+
...entries[i],
|
|
581
|
+
text: replaceAuthorInText(entries[i].text, items[i], substitute),
|
|
582
|
+
html: replaceAuthorInHtml(entries[i].html, items[i], substitute),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
prevAuthorKey = key
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Generate a stable key for comparing authors across bibliography entries.
|
|
591
|
+
*/
|
|
592
|
+
function authorKey(item) {
|
|
593
|
+
const names = item.author || item.editor || []
|
|
594
|
+
if (!names.length) return ''
|
|
595
|
+
return names.map(n => {
|
|
596
|
+
if (n.literal) return n.literal
|
|
597
|
+
return [n.family, n.given, n['non-dropping-particle'], n['dropping-particle']]
|
|
598
|
+
.filter(Boolean).join('|')
|
|
599
|
+
}).join(';')
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Generate a key from author family names only (for collapsing).
|
|
604
|
+
* Ignores given names — two items with the same family names collapse.
|
|
605
|
+
*/
|
|
606
|
+
function authorOnlyKey(item) {
|
|
607
|
+
const names = item.author || item.editor || []
|
|
608
|
+
if (!names.length) return ''
|
|
609
|
+
return names.map(n => {
|
|
610
|
+
if (n.literal) return n.literal
|
|
611
|
+
const ndp = n['non-dropping-particle'] || ''
|
|
612
|
+
const dp = n['dropping-particle'] || ''
|
|
613
|
+
return [dp, ndp, n.family].filter(Boolean).join(' ')
|
|
614
|
+
}).join(';')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Replace the author portion in text output with the substitute string.
|
|
619
|
+
*/
|
|
620
|
+
function replaceAuthorInText(text, item, substitute) {
|
|
621
|
+
const names = item.author || item.editor || []
|
|
622
|
+
if (!names.length) return text
|
|
623
|
+
|
|
624
|
+
const firstFamily = names[0].literal || names[0].family || ''
|
|
625
|
+
if (!firstFamily) return text
|
|
626
|
+
|
|
627
|
+
const idx = text.indexOf(firstFamily)
|
|
628
|
+
if (idx < 0) return text
|
|
629
|
+
|
|
630
|
+
const afterAuthor = text.indexOf('. (', idx)
|
|
631
|
+
const afterAuthor2 = text.indexOf('. ', idx)
|
|
632
|
+
const endIdx = afterAuthor >= 0 ? afterAuthor : afterAuthor2 >= 0 ? afterAuthor2 : -1
|
|
633
|
+
if (endIdx < 0) return text
|
|
634
|
+
|
|
635
|
+
return substitute + text.slice(endIdx)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Replace the author portion in HTML output with the substitute string.
|
|
640
|
+
*/
|
|
641
|
+
function replaceAuthorInHtml(html, item, substitute) {
|
|
642
|
+
return html.replace(
|
|
643
|
+
/<span class="csl-author">.*?<\/span>/,
|
|
644
|
+
`<span class="csl-author">${escapeForHtml(substitute)}</span>`
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function escapeForHtml(str) {
|
|
649
|
+
return str
|
|
650
|
+
.replace(/&/g, '&')
|
|
651
|
+
.replace(/</g, '<')
|
|
652
|
+
.replace(/>/g, '>')
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Generate a key combining author names and year for year-suffix detection.
|
|
657
|
+
*/
|
|
658
|
+
function authorYearKey(item) {
|
|
659
|
+
const names = item.author || item.editor || []
|
|
660
|
+
if (!names.length) return ''
|
|
661
|
+
const nameStr = names.map(n => {
|
|
662
|
+
if (n.literal) return n.literal
|
|
663
|
+
return (n.family || '') + '|' + (n.given || '')
|
|
664
|
+
}).join(';')
|
|
665
|
+
const year = item.issued?.['date-parts']?.[0]?.[0]
|
|
666
|
+
if (year == null) return ''
|
|
667
|
+
return nameStr + '/' + year
|
|
668
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createRegistry, Registry, RegistryOptions } from '@citestyle/types'
|