@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,196 @@
1
+ /**
2
+ * Citation Component
3
+ *
4
+ * Renders an inline citation that links to the bibliography.
5
+ *
6
+ * @module @uniweb/scholar/citations/Citation
7
+ */
8
+
9
+ import React, { useMemo } from 'react'
10
+ 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
+
43
+ /**
44
+ * Citation - Inline citation component
45
+ *
46
+ * @param {Object} props
47
+ * @param {string} [props.id] - Single reference ID to cite
48
+ * @param {Array<string>} [props.ids] - Multiple reference IDs to cite
49
+ * @param {string} [props.page] - Page number(s)
50
+ * @param {string} [props.prefix] - Text before citation (e.g., "see")
51
+ * @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
+ * @param {string} [props.className] - Additional CSS classes
55
+ *
56
+ * @example
57
+ * // Parenthetical citation: (Smith, 2024)
58
+ * <Citation id="smith2024" />
59
+ *
60
+ * @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" />
67
+ *
68
+ * @example
69
+ * // Multiple sources: (Smith, 2024; Jones, 2023)
70
+ * <Citation ids={['smith2024', 'jones2023']} />
71
+ *
72
+ * @example
73
+ * // IEEE-style numeric: [1]
74
+ * <Citation id="smith2024" format="numeric" />
75
+ */
76
+ export function Citation({
77
+ id,
78
+ ids,
79
+ page,
80
+ prefix,
81
+ suffix,
82
+ suppressAuthor = false,
83
+ format = 'parenthetical',
84
+ className,
85
+ ...props
86
+ }) {
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
+ }
181
+
182
+ // Single link for all citations
183
+ const firstId = citations[0]?.id
184
+ return (
185
+ <a
186
+ href={firstId ? `#ref-${firstId}` : undefined}
187
+ className={className}
188
+ style={{ textDecoration: 'none' }}
189
+ {...props}
190
+ >
191
+ {citationText}
192
+ </a>
193
+ )
194
+ }
195
+
196
+ export default Citation
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Citation Provider Component
3
+ *
4
+ * Provides context for managing citations throughout a document.
5
+ * Tracks which references have been cited and their order.
6
+ *
7
+ * @module @uniweb/scholar/citations/CitationProvider
8
+ */
9
+
10
+ import React, { createContext, useContext, useState, useMemo, useCallback } from 'react'
11
+ import { parseBibtex } from '../bibliography/parsers/bibtex.js'
12
+
13
+ /**
14
+ * Citation context
15
+ */
16
+ export const CitationContext = createContext(null)
17
+
18
+ /**
19
+ * Hook to access citation context
20
+ *
21
+ * @returns {{ cite: Function, getCitation: Function, references: Array, style: string, cited: Map }}
22
+ */
23
+ export function useCitation() {
24
+ const ctx = useContext(CitationContext)
25
+ if (!ctx) {
26
+ throw new Error('useCitation must be used within a CitationProvider')
27
+ }
28
+ return ctx
29
+ }
30
+
31
+ /**
32
+ * CitationProvider - Manages citation state for a document
33
+ *
34
+ * @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)
37
+ * @param {React.ReactNode} props.children
38
+ *
39
+ * @example
40
+ * <CitationProvider references={refs} style="apa">
41
+ * <p>According to <Citation id="smith2024" />, this is true.</p>
42
+ * <Bibliography />
43
+ * </CitationProvider>
44
+ */
45
+ 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
57
+ }
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
+ */
75
+ 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]
94
+ )
95
+
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]
108
+ )
109
+
110
+ /**
111
+ * Get reference by id
112
+ */
113
+ const getReference = useCallback(
114
+ (id) => refsById.get(id),
115
+ [refsById]
116
+ )
117
+
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
+ const value = useMemo(
127
+ () => ({
128
+ cite,
129
+ getCitation,
130
+ getReference,
131
+ getCitedReferences,
132
+ references: refs,
133
+ refsById,
134
+ style,
135
+ cited,
136
+ }),
137
+ [cite, getCitation, getReference, getCitedReferences, refs, refsById, style, cited]
138
+ )
139
+
140
+ return (
141
+ <CitationContext.Provider value={value}>
142
+ {children}
143
+ </CitationContext.Provider>
144
+ )
145
+ }
146
+
147
+ export default CitationProvider
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Citations Module
3
+ *
4
+ * Components for managing inline citations and bibliographies.
5
+ *
6
+ * @module @uniweb/scholar/citations
7
+ */
8
+
9
+ export {
10
+ CitationProvider,
11
+ CitationContext,
12
+ useCitation,
13
+ } from './CitationProvider.jsx'
14
+
15
+ export { Citation } from './Citation.jsx'
16
+ export { Bibliography } from './Bibliography.jsx'
@@ -0,0 +1,147 @@
1
+ /**
2
+ * AuthorList Component
3
+ *
4
+ * Renders a formatted list of authors with optional ORCID links.
5
+ *
6
+ * @module @uniweb/scholar/components/AuthorList
7
+ */
8
+
9
+ import React from 'react'
10
+ import { formatAuthors } from '../bibliography/utils/authors.js'
11
+
12
+ /**
13
+ * AuthorList - Formatted author names
14
+ *
15
+ * @param {Object} props
16
+ * @param {Array|string} props.authors - Authors array or string
17
+ * @param {string} [props.style='apa'] - Formatting style
18
+ * @param {boolean} [props.showOrcid=true] - Show ORCID links if available
19
+ * @param {string} [props.separator=', '] - Separator between authors
20
+ * @param {string} [props.className] - Additional CSS classes
21
+ *
22
+ * @example
23
+ * <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
+ */
28
+ export function AuthorList({
29
+ authors,
30
+ style = 'apa',
31
+ showOrcid = true,
32
+ separator = ', ',
33
+ className,
34
+ ...props
35
+ }) {
36
+ // If authors is a string, just format it
37
+ if (typeof authors === 'string') {
38
+ return (
39
+ <span className={className} {...props}>
40
+ {formatAuthors(authors, { style })}
41
+ </span>
42
+ )
43
+ }
44
+
45
+ // If no array or empty, return nothing
46
+ if (!authors?.length) {
47
+ return null
48
+ }
49
+
50
+ // Check if any author has ORCID
51
+ const hasOrcid = showOrcid && authors.some((a) => a.orcid)
52
+
53
+ // If no ORCID to show, use simple formatted string
54
+ if (!hasOrcid) {
55
+ return (
56
+ <span className={className} {...props}>
57
+ {formatAuthors(authors, { style })}
58
+ </span>
59
+ )
60
+ }
61
+
62
+ // Render with ORCID links
63
+ return (
64
+ <span className={className} {...props}>
65
+ {authors.map((author, index) => {
66
+ const isLast = index === authors.length - 1
67
+ const isSecondToLast = index === authors.length - 2
68
+
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
+ }
93
+
94
+ // Determine separator
95
+ let sep = ''
96
+ if (!isLast) {
97
+ if (authors.length === 2) {
98
+ sep = style === 'apa' ? ' & ' : ' and '
99
+ } else if (isSecondToLast) {
100
+ sep = style === 'apa' ? ', & ' : ', and '
101
+ } else {
102
+ sep = separator
103
+ }
104
+ }
105
+
106
+ return (
107
+ <React.Fragment key={index}>
108
+ <span style={{ whiteSpace: 'nowrap' }}>
109
+ {name}
110
+ {author.orcid && (
111
+ <a
112
+ href={`https://orcid.org/${author.orcid}`}
113
+ target="_blank"
114
+ rel="noopener noreferrer"
115
+ title={`ORCID: ${author.orcid}`}
116
+ style={{
117
+ marginLeft: '0.25rem',
118
+ display: 'inline-flex',
119
+ verticalAlign: 'middle',
120
+ }}
121
+ >
122
+ <svg
123
+ style={{ width: '1rem', height: '1rem' }}
124
+ viewBox="0 0 256 256"
125
+ xmlns="http://www.w3.org/2000/svg"
126
+ >
127
+ <path
128
+ d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0z"
129
+ fill="#A6CE39"
130
+ />
131
+ <path
132
+ d="M86.3 186.2H70.9V79.1h15.4v107.1zm22.7 0h-15V79.1h41.6c32.1 0 46.3 14.2 46.3 38.4 0 24.9-15.2 40.3-46.9 40.3h-26v28.4zm0-43.7h24.5c22.1 0 32.6-8.6 32.6-25.1 0-17.7-11.1-25-31.6-25h-25.5v50.1zm-43.4-79.4c5.9 0 10.6 4.8 10.6 10.6 0 5.9-4.8 10.6-10.6 10.6S55 79.6 55 73.7c0-5.8 4.8-10.6 10.6-10.6z"
133
+ fill="#fff"
134
+ />
135
+ </svg>
136
+ </a>
137
+ )}
138
+ </span>
139
+ {sep}
140
+ </React.Fragment>
141
+ )
142
+ })}
143
+ </span>
144
+ )
145
+ }
146
+
147
+ export default AuthorList
@@ -0,0 +1,160 @@
1
+ /**
2
+ * BibtexBlock Component
3
+ *
4
+ * Displays a BibTeX entry with copy functionality.
5
+ *
6
+ * @module @uniweb/scholar/components/BibtexBlock
7
+ */
8
+
9
+ import React, { useState } from 'react'
10
+ import { exportBibtex } from '../bibliography/parsers/bibtex.js'
11
+
12
+ /**
13
+ * BibtexBlock - Display copyable BibTeX entry
14
+ *
15
+ * @param {Object} props
16
+ * @param {Object|string} props.entry - Publication object or BibTeX string
17
+ * @param {boolean} [props.showCopyButton=true] - Show copy button
18
+ * @param {string} [props.className] - Additional CSS classes
19
+ *
20
+ * @example
21
+ * <BibtexBlock entry={publication} />
22
+ *
23
+ * @example
24
+ * <BibtexBlock entry="@article{smith2024, ...}" />
25
+ */
26
+ export function BibtexBlock({
27
+ entry,
28
+ showCopyButton = true,
29
+ className,
30
+ ...props
31
+ }) {
32
+ const [copied, setCopied] = useState(false)
33
+
34
+ // Get BibTeX string
35
+ const bibtex = typeof entry === 'string' ? entry : exportBibtex(entry)
36
+
37
+ // Copy to clipboard
38
+ async function handleCopy() {
39
+ try {
40
+ await navigator.clipboard.writeText(bibtex)
41
+ setCopied(true)
42
+ setTimeout(() => setCopied(false), 2000)
43
+ } catch (err) {
44
+ console.error('Failed to copy:', err)
45
+ }
46
+ }
47
+
48
+ return (
49
+ <div
50
+ className={className}
51
+ style={{
52
+ position: 'relative',
53
+ backgroundColor: '#f8fafc',
54
+ border: '1px solid #e2e8f0',
55
+ borderRadius: '0.5rem',
56
+ overflow: 'hidden',
57
+ }}
58
+ {...props}
59
+ >
60
+ {/* Header */}
61
+ <div
62
+ style={{
63
+ display: 'flex',
64
+ justifyContent: 'space-between',
65
+ alignItems: 'center',
66
+ padding: '0.5rem 1rem',
67
+ backgroundColor: '#f1f5f9',
68
+ borderBottom: '1px solid #e2e8f0',
69
+ }}
70
+ >
71
+ <span
72
+ style={{
73
+ fontSize: '0.75rem',
74
+ fontWeight: '600',
75
+ color: '#64748b',
76
+ textTransform: 'uppercase',
77
+ letterSpacing: '0.05em',
78
+ }}
79
+ >
80
+ BibTeX
81
+ </span>
82
+
83
+ {showCopyButton && (
84
+ <button
85
+ type="button"
86
+ onClick={handleCopy}
87
+ style={{
88
+ display: 'inline-flex',
89
+ alignItems: 'center',
90
+ gap: '0.25rem',
91
+ padding: '0.25rem 0.5rem',
92
+ fontSize: '0.75rem',
93
+ color: copied ? '#059669' : '#64748b',
94
+ backgroundColor: copied ? '#ecfdf5' : 'transparent',
95
+ border: 'none',
96
+ borderRadius: '0.25rem',
97
+ cursor: 'pointer',
98
+ }}
99
+ >
100
+ {copied ? (
101
+ <>
102
+ <svg
103
+ style={{ width: '0.875rem', height: '0.875rem' }}
104
+ fill="none"
105
+ stroke="currentColor"
106
+ viewBox="0 0 24 24"
107
+ >
108
+ <path
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ strokeWidth={2}
112
+ d="M5 13l4 4L19 7"
113
+ />
114
+ </svg>
115
+ Copied
116
+ </>
117
+ ) : (
118
+ <>
119
+ <svg
120
+ style={{ width: '0.875rem', height: '0.875rem' }}
121
+ fill="none"
122
+ stroke="currentColor"
123
+ viewBox="0 0 24 24"
124
+ >
125
+ <path
126
+ strokeLinecap="round"
127
+ strokeLinejoin="round"
128
+ strokeWidth={2}
129
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
130
+ />
131
+ </svg>
132
+ Copy
133
+ </>
134
+ )}
135
+ </button>
136
+ )}
137
+ </div>
138
+
139
+ {/* BibTeX content */}
140
+ <pre
141
+ style={{
142
+ margin: 0,
143
+ padding: '1rem',
144
+ fontSize: '0.875rem',
145
+ lineHeight: '1.6',
146
+ fontFamily:
147
+ 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace',
148
+ color: '#334155',
149
+ overflowX: 'auto',
150
+ whiteSpace: 'pre-wrap',
151
+ wordBreak: 'break-word',
152
+ }}
153
+ >
154
+ {bibtex}
155
+ </pre>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ export default BibtexBlock