@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 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'
@@ -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, '&amp;')
651
+ .replace(/</g, '&lt;')
652
+ .replace(/>/g, '&gt;')
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'