@uniweb/scholar 0.1.0 → 0.2.1

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.
@@ -1,44 +1,15 @@
1
1
  /**
2
2
  * Citation Component
3
3
  *
4
- * Renders an inline citation that links to the bibliography.
4
+ * Renders an inline citation using @citestyle/registry for proper
5
+ * author-date formatting, year-suffix disambiguation, numeric citations,
6
+ * and cite collapsing.
5
7
  *
6
8
  * @module @uniweb/scholar/citations/Citation
7
9
  */
8
10
 
9
11
  import React, { useMemo } from 'react'
10
12
  import { useCitation } from './CitationProvider.jsx'
11
- import { getYear } from '../bibliography/utils/dates.js'
12
-
13
- /**
14
- * Format author names for inline citation
15
- *
16
- * @param {Array} authors - Array of author objects
17
- * @param {string} style - Citation style
18
- * @returns {string}
19
- */
20
- function formatInlineAuthors(authors, style) {
21
- if (!authors?.length) return ''
22
-
23
- const getName = (a) => {
24
- if (typeof a === 'string') return a.split(',')[0].trim()
25
- return a.family || a.lastName || ''
26
- }
27
-
28
- if (authors.length === 1) {
29
- return getName(authors[0])
30
- }
31
-
32
- if (authors.length === 2) {
33
- if (style === 'apa') {
34
- return `${getName(authors[0])} & ${getName(authors[1])}`
35
- }
36
- return `${getName(authors[0])} and ${getName(authors[1])}`
37
- }
38
-
39
- // 3+ authors
40
- return `${getName(authors[0])} et al.`
41
- }
42
13
 
43
14
  /**
44
15
  * Citation - Inline citation component
@@ -46,11 +17,10 @@ function formatInlineAuthors(authors, style) {
46
17
  * @param {Object} props
47
18
  * @param {string} [props.id] - Single reference ID to cite
48
19
  * @param {Array<string>} [props.ids] - Multiple reference IDs to cite
49
- * @param {string} [props.page] - Page number(s)
20
+ * @param {string} [props.locator] - Page, chapter, or other locator
21
+ * @param {string} [props.label] - Locator label (e.g., 'page', 'chapter')
50
22
  * @param {string} [props.prefix] - Text before citation (e.g., "see")
51
23
  * @param {string} [props.suffix] - Text after citation
52
- * @param {boolean} [props.suppressAuthor] - Show only year (for narrative citations)
53
- * @param {string} [props.format='parenthetical'] - Format: 'parenthetical', 'narrative', or 'numeric'
54
24
  * @param {string} [props.className] - Additional CSS classes
55
25
  *
56
26
  * @example
@@ -58,138 +28,58 @@ function formatInlineAuthors(authors, style) {
58
28
  * <Citation id="smith2024" />
59
29
  *
60
30
  * @example
61
- * // Narrative citation: Smith (2024)
62
- * <Citation id="smith2024" format="narrative" />
63
- *
64
- * @example
65
- * // With page number: (Smith, 2024, p. 42)
66
- * <Citation id="smith2024" page="42" />
31
+ * // With page locator: (Smith, 2024, p. 42)
32
+ * <Citation id="smith2024" locator="42" label="page" />
67
33
  *
68
34
  * @example
69
35
  * // Multiple sources: (Smith, 2024; Jones, 2023)
70
36
  * <Citation ids={['smith2024', 'jones2023']} />
71
37
  *
72
38
  * @example
73
- * // IEEE-style numeric: [1]
74
- * <Citation id="smith2024" format="numeric" />
39
+ * // With prefix: (see Smith, 2024)
40
+ * <Citation id="smith2024" prefix="see" />
75
41
  */
76
42
  export function Citation({
77
43
  id,
78
44
  ids,
79
- page,
45
+ locator,
46
+ label = 'page',
80
47
  prefix,
81
48
  suffix,
82
- suppressAuthor = false,
83
- format = 'parenthetical',
84
49
  className,
85
50
  ...props
86
51
  }) {
87
- const { cite, getReference, style } = useCitation()
88
-
89
- // Get all IDs to cite
90
- const allIds = useMemo(() => {
91
- if (ids?.length) return ids
92
- if (id) return [id]
93
- return []
94
- }, [id, ids])
95
-
96
- // Cite all references and get their info
97
- const citations = useMemo(() => {
98
- return allIds.map((refId) => {
99
- const number = cite(refId)
100
- const reference = getReference(refId)
101
- return { id: refId, number, reference }
102
- })
103
- }, [allIds, cite, getReference])
104
-
105
- // Format the citation text
106
- const citationText = useMemo(() => {
107
- if (!citations.length) return '??'
108
-
109
- // Numeric format (IEEE style)
110
- if (format === 'numeric' || style === 'ieee') {
111
- const numbers = citations.map((c) => c.number).join(', ')
112
- return `[${numbers}]`
113
- }
114
-
115
- // Author-date formats
116
- const parts = citations.map((c) => {
117
- if (!c.reference) return c.id
118
-
119
- const authors = c.reference.authors || []
120
- const year = getYear(c.reference.year || c.reference.date)
121
- const authorStr = formatInlineAuthors(authors, style)
122
-
123
- if (suppressAuthor) {
124
- return year || 'n.d.'
125
- }
126
-
127
- if (!year) {
128
- return authorStr || c.id
129
- }
130
-
131
- return `${authorStr}, ${year}`
132
- })
133
-
134
- let text = parts.join('; ')
135
-
136
- // Add page number
137
- if (page) {
138
- const pagePrefix = page.includes('-') || page.includes('–') ? 'pp.' : 'p.'
139
- text += `, ${pagePrefix} ${page}`
140
- }
141
-
142
- // Add prefix/suffix
143
- if (prefix) text = `${prefix} ${text}`
144
- if (suffix) text = `${text}, ${suffix}`
145
-
146
- // Wrap in parentheses for parenthetical format
147
- if (format === 'parenthetical') {
148
- return `(${text})`
149
- }
150
-
151
- return text
152
- }, [citations, format, style, suppressAuthor, page, prefix, suffix])
153
-
154
- // For narrative format, we need to split author and year
155
- if (format === 'narrative' && citations.length === 1 && citations[0].reference) {
156
- const c = citations[0]
157
- const authors = c.reference.authors || []
158
- const year = getYear(c.reference.year || c.reference.date)
159
- const authorStr = formatInlineAuthors(authors, style)
160
-
161
- let yearPart = year || 'n.d.'
162
- if (page) {
163
- const pagePrefix = page.includes('-') || page.includes('–') ? 'pp.' : 'p.'
164
- yearPart += `, ${pagePrefix} ${page}`
165
- }
166
-
167
- return (
168
- <span className={className} {...props}>
169
- {prefix && `${prefix} `}
170
- {authorStr}{' '}
171
- <a
172
- href={`#ref-${c.id}`}
173
- style={{ textDecoration: 'none' }}
174
- >
175
- ({yearPart})
176
- </a>
177
- {suffix && ` ${suffix}`}
178
- </span>
179
- )
180
- }
52
+ const { cite } = useCitation()
53
+
54
+ // Build cite references array
55
+ const citeRefs = useMemo(() => {
56
+ const allIds = ids?.length ? ids : id ? [id] : []
57
+ return allIds.map((refId, i) => ({
58
+ id: refId,
59
+ // Only the first cite gets the locator/prefix/suffix
60
+ ...(i === 0 && locator ? { locator, label } : {}),
61
+ ...(i === 0 && prefix ? { prefix } : {}),
62
+ ...(i === allIds.length - 1 && suffix ? { suffix } : {}),
63
+ }))
64
+ }, [id, ids, locator, label, prefix, suffix])
65
+
66
+ // Format through the registry — handles disambiguation, collapsing, numbering
67
+ const result = useMemo(() => {
68
+ if (!citeRefs.length) return { text: '??', html: '??' }
69
+ return cite(citeRefs)
70
+ }, [citeRefs, cite])
71
+
72
+ // Link to bibliography entry
73
+ const firstId = citeRefs[0]?.id
181
74
 
182
- // Single link for all citations
183
- const firstId = citations[0]?.id
184
75
  return (
185
76
  <a
186
77
  href={firstId ? `#ref-${firstId}` : undefined}
187
78
  className={className}
188
79
  style={{ textDecoration: 'none' }}
80
+ dangerouslySetInnerHTML={{ __html: result.html }}
189
81
  {...props}
190
- >
191
- {citationText}
192
- </a>
82
+ />
193
83
  )
194
84
  }
195
85
 
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Citation Provider Component
3
3
  *
4
- * Provides context for managing citations throughout a document.
5
- * Tracks which references have been cited and their order.
4
+ * Provides citation context powered by @citestyle/registry.
5
+ * Handles year-suffix disambiguation, bibliography sorting, cite collapsing,
6
+ * and numeric citation numbering — all automatically.
6
7
  *
7
8
  * @module @uniweb/scholar/citations/CitationProvider
8
9
  */
9
10
 
10
- import React, { createContext, useContext, useState, useMemo, useCallback } from 'react'
11
- import { parseBibtex } from '../bibliography/parsers/bibtex.js'
11
+ import React, { createContext, useContext, useMemo, useCallback } from 'react'
12
+ import { createRegistry } from '@citestyle/registry'
13
+ import { parseBibtex } from '../bibliography/parsers/index.js'
14
+ import { compiledStyles } from '../bibliography/formatters/index.js'
15
+ import { toCslJson } from '../bibliography/normalize.js'
12
16
 
13
17
  /**
14
18
  * Citation context
@@ -18,7 +22,15 @@ export const CitationContext = createContext(null)
18
22
  /**
19
23
  * Hook to access citation context
20
24
  *
21
- * @returns {{ cite: Function, getCitation: Function, references: Array, style: string, cited: Map }}
25
+ * @returns {{
26
+ * registry: Object,
27
+ * cite: Function,
28
+ * getBibliography: Function,
29
+ * getReference: Function,
30
+ * references: Array,
31
+ * style: string,
32
+ * styleMeta: Object
33
+ * }}
22
34
  */
23
35
  export function useCitation() {
24
36
  const ctx = useContext(CitationContext)
@@ -28,113 +40,99 @@ export function useCitation() {
28
40
  return ctx
29
41
  }
30
42
 
43
+ /**
44
+ * Normalize references to CSL-JSON items.
45
+ * Accepts: BibTeX string, array of Scholar pubs, array of CSL-JSON items.
46
+ */
47
+ function normalizeReferences(references) {
48
+ if (!references) return []
49
+
50
+ // BibTeX string → parse to CSL-JSON
51
+ if (typeof references === 'string') {
52
+ return parseBibtex(references)
53
+ }
54
+
55
+ if (!Array.isArray(references)) return []
56
+
57
+ // Detect if items are already CSL-JSON (have `issued` or `container-title`)
58
+ // or Scholar-shaped (have `authors`, `journal`, `year` as number)
59
+ return references.map((ref) => {
60
+ if (ref.issued || ref['container-title'] || ref['date-parts']) {
61
+ // Already CSL-JSON — ensure it has an id
62
+ return { id: ref.id || ref.citationKey || 'unknown', ...ref }
63
+ }
64
+ // Scholar-shaped → convert
65
+ return toCslJson(ref)
66
+ })
67
+ }
68
+
31
69
  /**
32
70
  * CitationProvider - Manages citation state for a document
33
71
  *
72
+ * Uses @citestyle/registry for proper disambiguation, sorting, and numbering.
73
+ *
34
74
  * @param {Object} props
35
- * @param {Array|string} props.references - Array of reference objects or BibTeX string
36
- * @param {string} [props.style='apa'] - Citation style (apa, mla, chicago, ieee)
75
+ * @param {Array|string} props.references - Array of publications (Scholar or CSL-JSON) or BibTeX string
76
+ * @param {string} [props.style='apa'] - Citation style name
37
77
  * @param {React.ReactNode} props.children
38
78
  *
39
79
  * @example
40
- * <CitationProvider references={refs} style="apa">
80
+ * <CitationProvider references={pubs} style="apa">
41
81
  * <p>According to <Citation id="smith2024" />, this is true.</p>
42
82
  * <Bibliography />
43
83
  * </CitationProvider>
44
84
  */
45
85
  export function CitationProvider({ references, style = 'apa', children }) {
46
- // Track cited references in order
47
- const [cited, setCited] = useState(new Map())
48
-
49
- // Parse references if BibTeX string
50
- const refs = useMemo(() => {
51
- if (!references) return []
52
- if (typeof references === 'string') {
53
- return parseBibtex(references)
54
- }
55
- if (Array.isArray(references)) {
56
- return references
86
+ // Resolve compiled style
87
+ const compiled = useMemo(
88
+ () => compiledStyles[style.toLowerCase()] || compiledStyles.apa,
89
+ [style]
90
+ )
91
+
92
+ // Normalize all references to CSL-JSON
93
+ const cslItems = useMemo(
94
+ () => normalizeReferences(references),
95
+ [references]
96
+ )
97
+
98
+ // Create registry — handles disambiguation, sorting, numbering
99
+ const registry = useMemo(() => {
100
+ const reg = createRegistry(compiled)
101
+ if (cslItems.length) {
102
+ reg.addItems(cslItems)
57
103
  }
58
- return []
59
- }, [references])
60
-
61
- // Create a lookup map by id
62
- const refsById = useMemo(() => {
63
- const map = new Map()
64
- refs.forEach((ref) => {
65
- if (ref.id) map.set(ref.id, ref)
66
- if (ref.citationKey) map.set(ref.citationKey, ref)
67
- })
68
- return map
69
- }, [refs])
70
-
71
- /**
72
- * Cite a reference by id
73
- * Returns the citation number (order in which it was first cited)
74
- */
104
+ return reg
105
+ }, [compiled, cslItems])
106
+
107
+ // Convenience: format an inline citation cluster
75
108
  const cite = useCallback(
76
- (id) => {
77
- // Check if already cited
78
- if (cited.has(id)) {
79
- return cited.get(id)
80
- }
81
-
82
- // Add to cited list
83
- const number = cited.size + 1
84
- setCited((prev) => {
85
- if (prev.has(id)) return prev
86
- const next = new Map(prev)
87
- next.set(id, number)
88
- return next
89
- })
90
-
91
- return number
92
- },
93
- [cited]
109
+ (cites, ctx) => registry.cite(cites, ctx),
110
+ [registry]
94
111
  )
95
112
 
96
- /**
97
- * Get citation info without citing
98
- */
99
- const getCitation = useCallback(
100
- (id) => {
101
- return {
102
- number: cited.get(id),
103
- reference: refsById.get(id),
104
- isCited: cited.has(id),
105
- }
106
- },
107
- [cited, refsById]
113
+ // Convenience: get the full formatted bibliography
114
+ const getBibliography = useCallback(
115
+ (ctx) => registry.getBibliography(ctx),
116
+ [registry]
108
117
  )
109
118
 
110
- /**
111
- * Get reference by id
112
- */
119
+ // Look up a CSL-JSON item by id
113
120
  const getReference = useCallback(
114
- (id) => refsById.get(id),
115
- [refsById]
121
+ (id) => registry.getItem(id),
122
+ [registry]
116
123
  )
117
124
 
118
- /**
119
- * Get all cited references in citation order
120
- */
121
- const getCitedReferences = useCallback(() => {
122
- const sorted = Array.from(cited.entries()).sort((a, b) => a[1] - b[1])
123
- return sorted.map(([id]) => refsById.get(id)).filter(Boolean)
124
- }, [cited, refsById])
125
-
126
125
  const value = useMemo(
127
126
  () => ({
127
+ registry,
128
128
  cite,
129
- getCitation,
129
+ getBibliography,
130
130
  getReference,
131
- getCitedReferences,
132
- references: refs,
133
- refsById,
131
+ references: cslItems,
134
132
  style,
135
- cited,
133
+ styleMeta: compiled.meta,
136
134
  }),
137
- [cite, getCitation, getReference, getCitedReferences, refs, refsById, style, cited]
135
+ [registry, cite, getBibliography, getReference, cslItems, style, compiled]
138
136
  )
139
137
 
140
138
  return (
@@ -2,15 +2,66 @@
2
2
  * AuthorList Component
3
3
  *
4
4
  * Renders a formatted list of authors with optional ORCID links.
5
+ * Works with both Scholar-shaped author objects and CSL-JSON name objects.
5
6
  *
6
7
  * @module @uniweb/scholar/components/AuthorList
7
8
  */
8
9
 
9
10
  import React from 'react'
10
- import { formatAuthors } from '../bibliography/utils/authors.js'
11
11
 
12
12
  /**
13
- * AuthorList - Formatted author names
13
+ * Format a single author name for display.
14
+ *
15
+ * @param {{ family: string, given?: string, suffix?: string }} author
16
+ * @param {boolean} invertFirst - Show as "Last, First" (true) or "First Last" (false)
17
+ * @returns {string}
18
+ */
19
+ function formatName(author, invertFirst = false) {
20
+ if (typeof author === 'string') return author
21
+
22
+ const family = author.family || ''
23
+ const given = author.given || ''
24
+ const suffix = author.suffix ? `, ${author.suffix}` : ''
25
+
26
+ if (!given) return family + suffix
27
+
28
+ return invertFirst
29
+ ? `${family}${suffix}, ${given}`
30
+ : `${given} ${family}${suffix}`
31
+ }
32
+
33
+ /**
34
+ * Format an array of authors into a display string.
35
+ *
36
+ * @param {Array} authors - Author objects
37
+ * @param {Object} options
38
+ * @param {string} [options.style='apa']
39
+ * @returns {string}
40
+ */
41
+ function formatAuthorsText(authors, { style = 'apa' } = {}) {
42
+ if (!authors?.length) return ''
43
+
44
+ const useAmpersand = style === 'apa'
45
+ const invertFirst = style === 'apa' || style === 'chicago' || style === 'mla'
46
+
47
+ if (authors.length === 1) {
48
+ return formatName(authors[0], invertFirst)
49
+ }
50
+
51
+ if (authors.length === 2) {
52
+ const sep = useAmpersand ? ' & ' : ' and '
53
+ return formatName(authors[0], invertFirst) + sep + formatName(authors[1])
54
+ }
55
+
56
+ // 3+ authors
57
+ const allButLast = authors.slice(0, -1).map((a, i) => formatName(a, i === 0 && invertFirst))
58
+ const last = formatName(authors[authors.length - 1])
59
+ const sep = useAmpersand ? ', & ' : ', and '
60
+ return allButLast.join(', ') + sep + last
61
+ }
62
+
63
+ /**
64
+ * AuthorList - Formatted author names with optional ORCID links
14
65
  *
15
66
  * @param {Object} props
16
67
  * @param {Array|string} props.authors - Authors array or string
@@ -21,9 +72,6 @@ import { formatAuthors } from '../bibliography/utils/authors.js'
21
72
  *
22
73
  * @example
23
74
  * <AuthorList authors={[{ family: 'Smith', given: 'John', orcid: '0000-0001-2345-6789' }]} />
24
- *
25
- * @example
26
- * <AuthorList authors="Smith, John and Doe, Jane" style="mla" />
27
75
  */
28
76
  export function AuthorList({
29
77
  authors,
@@ -33,71 +81,48 @@ export function AuthorList({
33
81
  className,
34
82
  ...props
35
83
  }) {
36
- // If authors is a string, just format it
84
+ // String authors — just display
37
85
  if (typeof authors === 'string') {
38
86
  return (
39
87
  <span className={className} {...props}>
40
- {formatAuthors(authors, { style })}
88
+ {authors}
41
89
  </span>
42
90
  )
43
91
  }
44
92
 
45
- // If no array or empty, return nothing
46
- if (!authors?.length) {
47
- return null
48
- }
93
+ if (!authors?.length) return null
49
94
 
50
- // Check if any author has ORCID
95
+ // If no ORCID to show, use simple formatted string
51
96
  const hasOrcid = showOrcid && authors.some((a) => a.orcid)
52
97
 
53
- // If no ORCID to show, use simple formatted string
54
98
  if (!hasOrcid) {
55
99
  return (
56
100
  <span className={className} {...props}>
57
- {formatAuthors(authors, { style })}
101
+ {formatAuthorsText(authors, { style })}
58
102
  </span>
59
103
  )
60
104
  }
61
105
 
62
106
  // Render with ORCID links
107
+ const useAmpersand = style === 'apa'
108
+
63
109
  return (
64
110
  <span className={className} {...props}>
65
111
  {authors.map((author, index) => {
66
112
  const isLast = index === authors.length - 1
67
113
  const isSecondToLast = index === authors.length - 2
114
+ const isFirst = index === 0
115
+ const invertFirst = isFirst && (style === 'apa' || style === 'chicago' || style === 'mla')
68
116
 
69
- // Format name based on style and position
70
- let name
71
- if (typeof author === 'string') {
72
- name = author
73
- } else {
74
- const suffix = author.suffix ? `, ${author.suffix}` : ''
75
- if (style === 'apa') {
76
- // APA: Last, F.
77
- const initials = author.given
78
- ? author.given
79
- .split(/\s+/)
80
- .map((n) => `${n.charAt(0)}.`)
81
- .join(' ')
82
- : ''
83
- name = initials
84
- ? `${author.family}${suffix}, ${initials}`
85
- : author.family + suffix
86
- } else {
87
- // Default: First Last
88
- name = author.given
89
- ? `${author.given} ${author.family}${suffix}`
90
- : author.family + suffix
91
- }
92
- }
117
+ const name = formatName(author, invertFirst)
93
118
 
94
119
  // Determine separator
95
120
  let sep = ''
96
121
  if (!isLast) {
97
122
  if (authors.length === 2) {
98
- sep = style === 'apa' ? ' & ' : ' and '
123
+ sep = useAmpersand ? ' & ' : ' and '
99
124
  } else if (isSecondToLast) {
100
- sep = style === 'apa' ? ', & ' : ', and '
125
+ sep = useAmpersand ? ', & ' : ', and '
101
126
  } else {
102
127
  sep = separator
103
128
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import React, { useState } from 'react'
10
- import { exportBibtex } from '../bibliography/parsers/bibtex.js'
10
+ import { exportBibtex } from '../bibliography/parsers/index.js'
11
11
 
12
12
  /**
13
13
  * BibtexBlock - Display copyable BibTeX entry