@uniweb/scholar 0.1.0

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Author Name Utilities
3
+ *
4
+ * Functions for parsing and formatting author names in various citation styles.
5
+ *
6
+ * @module @uniweb/scholar/bibliography/utils/authors
7
+ */
8
+
9
+ /**
10
+ * Parse a single author name string into structured parts
11
+ *
12
+ * @param {string} name - Author name (e.g., "Smith, John A." or "John A. Smith")
13
+ * @returns {{ family: string, given: string, suffix?: string }}
14
+ */
15
+ export function parseAuthorName(name) {
16
+ if (!name || typeof name !== 'string') {
17
+ return { family: '', given: '' }
18
+ }
19
+
20
+ const trimmed = name.trim()
21
+
22
+ // Check for "Last, First" format
23
+ if (trimmed.includes(',')) {
24
+ const parts = trimmed.split(',').map((p) => p.trim())
25
+ const family = parts[0]
26
+ const givenAndSuffix = parts.slice(1).join(' ').trim()
27
+
28
+ // Check for suffix (Jr., Sr., III, etc.)
29
+ const suffixMatch = givenAndSuffix.match(/\s+(Jr\.?|Sr\.?|III?|IV|V)$/i)
30
+ if (suffixMatch) {
31
+ return {
32
+ family,
33
+ given: givenAndSuffix.slice(0, suffixMatch.index).trim(),
34
+ suffix: suffixMatch[1],
35
+ }
36
+ }
37
+
38
+ return { family, given: givenAndSuffix }
39
+ }
40
+
41
+ // "First Last" format - last word is family name
42
+ const words = trimmed.split(/\s+/)
43
+ if (words.length === 1) {
44
+ return { family: words[0], given: '' }
45
+ }
46
+
47
+ // Check if last word is a suffix
48
+ const lastWord = words[words.length - 1]
49
+ if (/^(Jr\.?|Sr\.?|III?|IV|V)$/i.test(lastWord)) {
50
+ return {
51
+ family: words[words.length - 2] || '',
52
+ given: words.slice(0, -2).join(' '),
53
+ suffix: lastWord,
54
+ }
55
+ }
56
+
57
+ return {
58
+ family: words[words.length - 1],
59
+ given: words.slice(0, -1).join(' '),
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Parse a string of multiple authors
65
+ *
66
+ * @param {string} authorString - Authors separated by "and", ";", or "&"
67
+ * @returns {Array<{ family: string, given: string, suffix?: string }>}
68
+ */
69
+ export function parseAuthorString(authorString) {
70
+ if (!authorString || typeof authorString !== 'string') {
71
+ return []
72
+ }
73
+
74
+ // Split by "and", ";", or "&"
75
+ const authors = authorString
76
+ .split(/\s+and\s+|;\s*|&\s*/i)
77
+ .map((a) => a.trim())
78
+ .filter(Boolean)
79
+
80
+ return authors.map(parseAuthorName)
81
+ }
82
+
83
+ /**
84
+ * Get author initials
85
+ *
86
+ * @param {string} given - Given name(s)
87
+ * @param {Object} [options]
88
+ * @param {boolean} [options.periods=true] - Include periods after initials
89
+ * @param {boolean} [options.spaces=true] - Include spaces between initials
90
+ * @returns {string}
91
+ */
92
+ export function getInitials(given, options = {}) {
93
+ const { periods = true, spaces = true } = options
94
+
95
+ if (!given) return ''
96
+
97
+ const names = given.split(/\s+/)
98
+ const initials = names
99
+ .map((name) => name.charAt(0).toUpperCase())
100
+ .map((initial) => (periods ? `${initial}.` : initial))
101
+
102
+ return initials.join(spaces ? ' ' : '')
103
+ }
104
+
105
+ /**
106
+ * Format authors for APA style
107
+ *
108
+ * @param {Array} authors - Array of parsed author objects
109
+ * @param {number} [maxAuthors=20] - Maximum authors before using et al.
110
+ * @returns {string}
111
+ */
112
+ export function formatApaAuthors(authors, maxAuthors = 20) {
113
+ if (!authors?.length) return ''
114
+
115
+ const formatOne = (author) => {
116
+ const initials = getInitials(author.given, { periods: true, spaces: true })
117
+ const suffix = author.suffix ? `, ${author.suffix}` : ''
118
+ return initials
119
+ ? `${author.family}${suffix}, ${initials}`
120
+ : author.family + suffix
121
+ }
122
+
123
+ if (authors.length === 1) {
124
+ return formatOne(authors[0])
125
+ }
126
+
127
+ if (authors.length === 2) {
128
+ return `${formatOne(authors[0])} & ${formatOne(authors[1])}`
129
+ }
130
+
131
+ if (authors.length <= maxAuthors) {
132
+ const allButLast = authors.slice(0, -1).map(formatOne).join(', ')
133
+ return `${allButLast}, & ${formatOne(authors[authors.length - 1])}`
134
+ }
135
+
136
+ // More than maxAuthors: first 19, ..., last
137
+ const first19 = authors.slice(0, 19).map(formatOne).join(', ')
138
+ return `${first19}, ... ${formatOne(authors[authors.length - 1])}`
139
+ }
140
+
141
+ /**
142
+ * Format authors for MLA style
143
+ *
144
+ * @param {Array} authors - Array of parsed author objects
145
+ * @returns {string}
146
+ */
147
+ export function formatMlaAuthors(authors) {
148
+ if (!authors?.length) return ''
149
+
150
+ const formatFirst = (author) => {
151
+ const suffix = author.suffix ? `, ${author.suffix}` : ''
152
+ return author.given
153
+ ? `${author.family}${suffix}, ${author.given}`
154
+ : author.family + suffix
155
+ }
156
+
157
+ const formatSubsequent = (author) => {
158
+ const suffix = author.suffix ? `, ${author.suffix}` : ''
159
+ return author.given
160
+ ? `${author.given} ${author.family}${suffix}`
161
+ : author.family + suffix
162
+ }
163
+
164
+ if (authors.length === 1) {
165
+ return formatFirst(authors[0])
166
+ }
167
+
168
+ if (authors.length === 2) {
169
+ return `${formatFirst(authors[0])}, and ${formatSubsequent(authors[1])}`
170
+ }
171
+
172
+ if (authors.length === 3) {
173
+ return `${formatFirst(authors[0])}, ${formatSubsequent(authors[1])}, and ${formatSubsequent(authors[2])}`
174
+ }
175
+
176
+ // 4+ authors: first author et al.
177
+ return `${formatFirst(authors[0])}, et al.`
178
+ }
179
+
180
+ /**
181
+ * Format authors for Chicago style (author-date)
182
+ *
183
+ * @param {Array} authors - Array of parsed author objects
184
+ * @returns {string}
185
+ */
186
+ export function formatChicagoAuthors(authors) {
187
+ if (!authors?.length) return ''
188
+
189
+ const formatOne = (author, isFirst = false) => {
190
+ const suffix = author.suffix ? `, ${author.suffix}` : ''
191
+ if (isFirst) {
192
+ return author.given
193
+ ? `${author.family}${suffix}, ${author.given}`
194
+ : author.family + suffix
195
+ }
196
+ return author.given
197
+ ? `${author.given} ${author.family}${suffix}`
198
+ : author.family + suffix
199
+ }
200
+
201
+ if (authors.length === 1) {
202
+ return formatOne(authors[0], true)
203
+ }
204
+
205
+ if (authors.length === 2) {
206
+ return `${formatOne(authors[0], true)} and ${formatOne(authors[1])}`
207
+ }
208
+
209
+ if (authors.length === 3) {
210
+ return `${formatOne(authors[0], true)}, ${formatOne(authors[1])}, and ${formatOne(authors[2])}`
211
+ }
212
+
213
+ // 4+ authors: first author et al.
214
+ return `${formatOne(authors[0], true)} et al.`
215
+ }
216
+
217
+ /**
218
+ * Format authors for IEEE style
219
+ *
220
+ * @param {Array} authors - Array of parsed author objects
221
+ * @returns {string}
222
+ */
223
+ export function formatIeeeAuthors(authors) {
224
+ if (!authors?.length) return ''
225
+
226
+ const formatOne = (author) => {
227
+ const initials = getInitials(author.given, { periods: true, spaces: true })
228
+ const suffix = author.suffix ? ` ${author.suffix}` : ''
229
+ return initials
230
+ ? `${initials} ${author.family}${suffix}`
231
+ : author.family + suffix
232
+ }
233
+
234
+ if (authors.length === 1) {
235
+ return formatOne(authors[0])
236
+ }
237
+
238
+ if (authors.length === 2) {
239
+ return `${formatOne(authors[0])} and ${formatOne(authors[1])}`
240
+ }
241
+
242
+ // 3+ authors in IEEE: first author et al. in citations, all in references
243
+ const allButLast = authors.slice(0, -1).map(formatOne).join(', ')
244
+ return `${allButLast}, and ${formatOne(authors[authors.length - 1])}`
245
+ }
246
+
247
+ /**
248
+ * Format authors for a specific style
249
+ *
250
+ * @param {Array|string} authors - Authors array or string
251
+ * @param {Object} [options]
252
+ * @param {string} [options.style='apa'] - Citation style
253
+ * @param {number} [options.max=20] - Max authors before et al.
254
+ * @returns {string}
255
+ */
256
+ export function formatAuthors(authors, options = {}) {
257
+ const { style = 'apa', max = 20 } = options
258
+
259
+ // Parse if string
260
+ const parsed =
261
+ typeof authors === 'string' ? parseAuthorString(authors) : authors
262
+
263
+ if (!parsed?.length) return ''
264
+
265
+ switch (style) {
266
+ case 'apa':
267
+ return formatApaAuthors(parsed, max)
268
+ case 'mla':
269
+ return formatMlaAuthors(parsed)
270
+ case 'chicago':
271
+ return formatChicagoAuthors(parsed)
272
+ case 'ieee':
273
+ return formatIeeeAuthors(parsed)
274
+ default:
275
+ return formatApaAuthors(parsed, max)
276
+ }
277
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Date Formatting Utilities
3
+ *
4
+ * Functions for formatting dates in various citation styles.
5
+ *
6
+ * @module @uniweb/scholar/bibliography/utils/dates
7
+ */
8
+
9
+ /**
10
+ * Month names for formatting
11
+ */
12
+ const MONTHS_FULL = [
13
+ 'January',
14
+ 'February',
15
+ 'March',
16
+ 'April',
17
+ 'May',
18
+ 'June',
19
+ 'July',
20
+ 'August',
21
+ 'September',
22
+ 'October',
23
+ 'November',
24
+ 'December',
25
+ ]
26
+
27
+ const MONTHS_SHORT = [
28
+ 'Jan.',
29
+ 'Feb.',
30
+ 'Mar.',
31
+ 'Apr.',
32
+ 'May',
33
+ 'June',
34
+ 'July',
35
+ 'Aug.',
36
+ 'Sep.',
37
+ 'Oct.',
38
+ 'Nov.',
39
+ 'Dec.',
40
+ ]
41
+
42
+ /**
43
+ * Parse a date from various formats
44
+ *
45
+ * @param {string|number|Date|Object} date - Date input
46
+ * @returns {{ year?: number, month?: number, day?: number }}
47
+ */
48
+ export function parseDate(date) {
49
+ if (!date) return {}
50
+
51
+ // Already structured
52
+ if (typeof date === 'object' && !(date instanceof Date)) {
53
+ return {
54
+ year: date.year ?? date.Year,
55
+ month: date.month ?? date.Month,
56
+ day: date.day ?? date.Day,
57
+ }
58
+ }
59
+
60
+ // JavaScript Date
61
+ if (date instanceof Date) {
62
+ return {
63
+ year: date.getFullYear(),
64
+ month: date.getMonth() + 1,
65
+ day: date.getDate(),
66
+ }
67
+ }
68
+
69
+ // Number (year only)
70
+ if (typeof date === 'number') {
71
+ return { year: date }
72
+ }
73
+
74
+ // String parsing
75
+ const str = String(date).trim()
76
+
77
+ // ISO format: YYYY-MM-DD
78
+ const isoMatch = str.match(/^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?/)
79
+ if (isoMatch) {
80
+ return {
81
+ year: parseInt(isoMatch[1], 10),
82
+ month: isoMatch[2] ? parseInt(isoMatch[2], 10) : undefined,
83
+ day: isoMatch[3] ? parseInt(isoMatch[3], 10) : undefined,
84
+ }
85
+ }
86
+
87
+ // Year only
88
+ const yearMatch = str.match(/^\d{4}$/)
89
+ if (yearMatch) {
90
+ return { year: parseInt(str, 10) }
91
+ }
92
+
93
+ // Month Year: "January 2024", "Jan 2024"
94
+ const monthYearMatch = str.match(
95
+ /^(\w+)\s+(\d{4})$/
96
+ )
97
+ if (monthYearMatch) {
98
+ const monthName = monthYearMatch[1].toLowerCase()
99
+ const monthIndex = MONTHS_FULL.findIndex(
100
+ (m) => m.toLowerCase().startsWith(monthName.slice(0, 3))
101
+ )
102
+ return {
103
+ year: parseInt(monthYearMatch[2], 10),
104
+ month: monthIndex !== -1 ? monthIndex + 1 : undefined,
105
+ }
106
+ }
107
+
108
+ return {}
109
+ }
110
+
111
+ /**
112
+ * Format date for APA style
113
+ *
114
+ * @param {Object} date - Parsed date object
115
+ * @returns {string}
116
+ */
117
+ export function formatApaDate(date) {
118
+ const parsed = parseDate(date)
119
+
120
+ if (!parsed.year) return 'n.d.'
121
+
122
+ if (parsed.month && parsed.day) {
123
+ return `${parsed.year}, ${MONTHS_FULL[parsed.month - 1]} ${parsed.day}`
124
+ }
125
+
126
+ if (parsed.month) {
127
+ return `${parsed.year}, ${MONTHS_FULL[parsed.month - 1]}`
128
+ }
129
+
130
+ return String(parsed.year)
131
+ }
132
+
133
+ /**
134
+ * Format date for MLA style
135
+ *
136
+ * @param {Object} date - Parsed date object
137
+ * @returns {string}
138
+ */
139
+ export function formatMlaDate(date) {
140
+ const parsed = parseDate(date)
141
+
142
+ if (!parsed.year) return ''
143
+
144
+ if (parsed.month && parsed.day) {
145
+ return `${parsed.day} ${MONTHS_SHORT[parsed.month - 1]} ${parsed.year}`
146
+ }
147
+
148
+ if (parsed.month) {
149
+ return `${MONTHS_SHORT[parsed.month - 1]} ${parsed.year}`
150
+ }
151
+
152
+ return String(parsed.year)
153
+ }
154
+
155
+ /**
156
+ * Format date for Chicago style
157
+ *
158
+ * @param {Object} date - Parsed date object
159
+ * @returns {string}
160
+ */
161
+ export function formatChicagoDate(date) {
162
+ const parsed = parseDate(date)
163
+
164
+ if (!parsed.year) return 'n.d.'
165
+
166
+ if (parsed.month && parsed.day) {
167
+ return `${MONTHS_FULL[parsed.month - 1]} ${parsed.day}, ${parsed.year}`
168
+ }
169
+
170
+ if (parsed.month) {
171
+ return `${MONTHS_FULL[parsed.month - 1]} ${parsed.year}`
172
+ }
173
+
174
+ return String(parsed.year)
175
+ }
176
+
177
+ /**
178
+ * Format date for IEEE style
179
+ *
180
+ * @param {Object} date - Parsed date object
181
+ * @returns {string}
182
+ */
183
+ export function formatIeeeDate(date) {
184
+ const parsed = parseDate(date)
185
+
186
+ if (!parsed.year) return ''
187
+
188
+ if (parsed.month) {
189
+ return `${MONTHS_SHORT[parsed.month - 1]} ${parsed.year}`
190
+ }
191
+
192
+ return String(parsed.year)
193
+ }
194
+
195
+ /**
196
+ * Format date for a specific style
197
+ *
198
+ * @param {any} date - Date input
199
+ * @param {Object} [options]
200
+ * @param {string} [options.style='apa'] - Citation style
201
+ * @returns {string}
202
+ */
203
+ export function formatDate(date, options = {}) {
204
+ const { style = 'apa' } = options
205
+
206
+ switch (style) {
207
+ case 'apa':
208
+ return formatApaDate(date)
209
+ case 'mla':
210
+ return formatMlaDate(date)
211
+ case 'chicago':
212
+ return formatChicagoDate(date)
213
+ case 'ieee':
214
+ return formatIeeeDate(date)
215
+ default:
216
+ return formatApaDate(date)
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Get year from date
222
+ *
223
+ * @param {any} date - Date input
224
+ * @returns {number|undefined}
225
+ */
226
+ export function getYear(date) {
227
+ return parseDate(date).year
228
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Bibliography Utilities
3
+ *
4
+ * @module @uniweb/scholar/bibliography/utils
5
+ */
6
+
7
+ export * from './authors.js'
8
+ export * from './dates.js'
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Bibliography Component
3
+ *
4
+ * Renders a formatted list of references that have been cited in the document.
5
+ *
6
+ * @module @uniweb/scholar/citations/Bibliography
7
+ */
8
+
9
+ import React, { useMemo } from 'react'
10
+ import { useCitation } from './CitationProvider.jsx'
11
+ import { formatReference } from '../bibliography/formatters/index.js'
12
+
13
+ /**
14
+ * Bibliography - Renders the list of cited references
15
+ *
16
+ * @param {Object} props
17
+ * @param {string} [props.title='References'] - Section title
18
+ * @param {boolean} [props.showTitle=true] - Whether to show the title
19
+ * @param {boolean} [props.showAll=false] - Show all references, not just cited ones
20
+ * @param {string} [props.className] - Additional CSS classes
21
+ * @param {string} [props.itemClassName] - CSS classes for each item
22
+ *
23
+ * @example
24
+ * <Bibliography title="Works Cited" />
25
+ *
26
+ * @example
27
+ * // Show all references including uncited ones
28
+ * <Bibliography showAll />
29
+ */
30
+ export function Bibliography({
31
+ title = 'References',
32
+ showTitle = true,
33
+ showAll = false,
34
+ className,
35
+ itemClassName,
36
+ ...props
37
+ }) {
38
+ const { getCitedReferences, references, style, cited } = useCitation()
39
+
40
+ // Get references to display
41
+ const refsToShow = useMemo(() => {
42
+ if (showAll) {
43
+ return references
44
+ }
45
+ return getCitedReferences()
46
+ }, [showAll, references, getCitedReferences])
47
+
48
+ // Format references
49
+ const formattedRefs = useMemo(() => {
50
+ return refsToShow.map((ref) => {
51
+ const number = cited.get(ref.id) || cited.get(ref.citationKey)
52
+ return {
53
+ id: ref.id || ref.citationKey,
54
+ number,
55
+ formatted: formatReference(ref, { style }),
56
+ reference: ref,
57
+ }
58
+ })
59
+ }, [refsToShow, style, cited])
60
+
61
+ if (!formattedRefs.length) {
62
+ return null
63
+ }
64
+
65
+ // Use numbered list for IEEE style
66
+ const isNumeric = style === 'ieee'
67
+
68
+ return (
69
+ <section className={className} {...props}>
70
+ {showTitle && (
71
+ <h2
72
+ style={{
73
+ fontSize: '1.5rem',
74
+ fontWeight: '600',
75
+ marginBottom: '1rem',
76
+ }}
77
+ >
78
+ {title}
79
+ </h2>
80
+ )}
81
+
82
+ {isNumeric ? (
83
+ <ol
84
+ style={{
85
+ listStyleType: 'decimal',
86
+ paddingLeft: '2rem',
87
+ lineHeight: '1.8',
88
+ }}
89
+ >
90
+ {formattedRefs.map((ref) => (
91
+ <li
92
+ key={ref.id}
93
+ id={`ref-${ref.id}`}
94
+ className={itemClassName}
95
+ style={{
96
+ marginBottom: '0.75rem',
97
+ }}
98
+ >
99
+ {ref.formatted}
100
+ </li>
101
+ ))}
102
+ </ol>
103
+ ) : (
104
+ <div
105
+ style={{
106
+ lineHeight: '1.8',
107
+ }}
108
+ >
109
+ {formattedRefs.map((ref) => (
110
+ <p
111
+ key={ref.id}
112
+ id={`ref-${ref.id}`}
113
+ className={itemClassName}
114
+ style={{
115
+ marginBottom: '0.75rem',
116
+ paddingLeft: '2rem',
117
+ textIndent: '-2rem',
118
+ }}
119
+ >
120
+ {ref.formatted}
121
+ </p>
122
+ ))}
123
+ </div>
124
+ )}
125
+ </section>
126
+ )
127
+ }
128
+
129
+ export default Bibliography