@uniweb/scholar 0.2.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,21 +1,22 @@
1
1
  /**
2
2
  * CiteButton Component
3
3
  *
4
- * A dropdown button that lets users copy citations in various formats.
4
+ * A dropdown button that lets users copy citations in various formats,
5
+ * including rich text (HTML with formatting preserved for paste into Word/Docs).
5
6
  *
6
7
  * @module @uniweb/scholar/components/CiteButton
7
8
  */
8
9
 
9
10
  import React, { useState, useRef, useEffect } from 'react'
10
- import { formatReference } from '../bibliography/formatters/index.js'
11
- import { exportBibtex } from '../bibliography/parsers/bibtex.js'
11
+ import { formatReference, getAvailableStyles } from '../bibliography/formatters/index.js'
12
+ import { exportBibtex } from '../bibliography/parsers/index.js'
12
13
 
13
14
  /**
14
15
  * CiteButton - Copy citation dropdown
15
16
  *
16
17
  * @param {Object} props
17
- * @param {Object} props.publication - Publication data
18
- * @param {Array<string>} [props.styles=['apa', 'mla', 'chicago', 'ieee', 'bibtex']] - Available citation styles
18
+ * @param {Object} props.publication - Publication data (Scholar or CSL-JSON)
19
+ * @param {Array<string>} [props.styles] - Style names to show (defaults to all + bibtex)
19
20
  * @param {string} [props.label='Cite'] - Button label
20
21
  * @param {string} [props.className] - Additional CSS classes
21
22
  *
@@ -24,7 +25,7 @@ import { exportBibtex } from '../bibliography/parsers/bibtex.js'
24
25
  */
25
26
  export function CiteButton({
26
27
  publication,
27
- styles = ['apa', 'mla', 'chicago', 'ieee', 'bibtex'],
28
+ styles,
28
29
  label = 'Cite',
29
30
  className,
30
31
  ...props
@@ -33,6 +34,16 @@ export function CiteButton({
33
34
  const [copied, setCopied] = useState(null)
34
35
  const dropdownRef = useRef(null)
35
36
 
37
+ // Default: all available citation styles + bibtex
38
+ const availableStyles = getAvailableStyles()
39
+ const styleIds = styles || [...availableStyles.map((s) => s.id), 'bibtex']
40
+
41
+ // Build label map from available styles
42
+ const styleLabels = Object.fromEntries(
43
+ availableStyles.map((s) => [s.id, s.label])
44
+ )
45
+ styleLabels.bibtex = 'BibTeX'
46
+
36
47
  // Close dropdown when clicking outside
37
48
  useEffect(() => {
38
49
  function handleClickOutside(event) {
@@ -47,17 +58,35 @@ export function CiteButton({
47
58
  }
48
59
  }, [isOpen])
49
60
 
50
- // Copy citation to clipboard
61
+ /**
62
+ * Copy citation to clipboard.
63
+ * For citation styles, copies both plain text and rich HTML.
64
+ */
51
65
  async function copyToClipboard(style) {
52
66
  try {
53
- let text
54
67
  if (style === 'bibtex') {
55
- text = exportBibtex(publication)
68
+ const text = exportBibtex(publication)
69
+ await navigator.clipboard.writeText(text)
56
70
  } else {
57
- text = formatReference(publication, { style })
71
+ const entry = formatReference(publication, { style })
72
+
73
+ // Try rich-text copy (HTML + plain text) for formatting in Word/Docs
74
+ if (typeof ClipboardItem !== 'undefined') {
75
+ try {
76
+ const item = new ClipboardItem({
77
+ 'text/html': new Blob([entry.html], { type: 'text/html' }),
78
+ 'text/plain': new Blob([entry.text], { type: 'text/plain' }),
79
+ })
80
+ await navigator.clipboard.write([item])
81
+ } catch {
82
+ // Fallback to plain text if rich copy fails
83
+ await navigator.clipboard.writeText(entry.text)
84
+ }
85
+ } else {
86
+ await navigator.clipboard.writeText(entry.text)
87
+ }
58
88
  }
59
89
 
60
- await navigator.clipboard.writeText(text)
61
90
  setCopied(style)
62
91
  setTimeout(() => setCopied(null), 2000)
63
92
  } catch (err) {
@@ -65,14 +94,6 @@ export function CiteButton({
65
94
  }
66
95
  }
67
96
 
68
- const styleLabels = {
69
- apa: 'APA',
70
- mla: 'MLA',
71
- chicago: 'Chicago',
72
- ieee: 'IEEE',
73
- bibtex: 'BibTeX',
74
- }
75
-
76
97
  return (
77
98
  <div
78
99
  ref={dropdownRef}
@@ -88,21 +109,34 @@ export function CiteButton({
88
109
  display: 'inline-flex',
89
110
  alignItems: 'center',
90
111
  gap: '0.25rem',
91
- padding: '0.375rem 0.75rem',
92
- fontSize: '0.875rem',
93
- fontWeight: '500',
94
- color: '#374151',
95
- backgroundColor: '#f3f4f6',
96
- border: '1px solid #d1d5db',
97
- borderRadius: '0.375rem',
112
+ padding: '0.125rem 0.5rem',
113
+ fontSize: '0.8125rem',
114
+ fontWeight: '400',
115
+ color: 'var(--subtle, #6b7280)',
116
+ backgroundColor: 'transparent',
117
+ border: 'none',
118
+ borderRadius: '0.25rem',
98
119
  cursor: 'pointer',
120
+ letterSpacing: '0.01em',
99
121
  }}
122
+ onMouseEnter={(e) => { e.target.style.color = 'var(--heading, #111827)' }}
123
+ onMouseLeave={(e) => { if (!isOpen) e.target.style.color = 'var(--subtle, #6b7280)' }}
100
124
  >
125
+ {/* Quote icon */}
126
+ <svg
127
+ style={{ width: '0.875rem', height: '0.875rem', opacity: 0.7, flexShrink: 0 }}
128
+ fill="none"
129
+ stroke="currentColor"
130
+ viewBox="0 0 24 24"
131
+ >
132
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
133
+ </svg>
101
134
  {label}
102
135
  <svg
103
136
  style={{
104
- width: '1rem',
105
- height: '1rem',
137
+ width: '0.625rem',
138
+ height: '0.625rem',
139
+ opacity: 0.5,
106
140
  transform: isOpen ? 'rotate(180deg)' : 'rotate(0)',
107
141
  transition: 'transform 0.15s',
108
142
  }}
@@ -110,12 +144,7 @@ export function CiteButton({
110
144
  stroke="currentColor"
111
145
  viewBox="0 0 24 24"
112
146
  >
113
- <path
114
- strokeLinecap="round"
115
- strokeLinejoin="round"
116
- strokeWidth={2}
117
- d="M19 9l-7 7-7-7"
118
- />
147
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
119
148
  </svg>
120
149
  </button>
121
150
 
@@ -127,16 +156,17 @@ export function CiteButton({
127
156
  top: '100%',
128
157
  left: 0,
129
158
  marginTop: '0.25rem',
130
- minWidth: '8rem',
131
- backgroundColor: '#ffffff',
132
- border: '1px solid #e5e7eb',
133
- borderRadius: '0.375rem',
134
- boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
159
+ minWidth: '8.5rem',
160
+ backgroundColor: 'var(--section, #ffffff)',
161
+ border: '1px solid var(--border, #e5e7eb)',
162
+ borderRadius: '0.5rem',
163
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
135
164
  zIndex: 50,
136
165
  overflow: 'hidden',
166
+ padding: '0.25rem',
137
167
  }}
138
168
  >
139
- {styles.map((style) => (
169
+ {styleIds.map((style) => (
140
170
  <button
141
171
  key={style}
142
172
  type="button"
@@ -144,17 +174,18 @@ export function CiteButton({
144
174
  style={{
145
175
  display: 'block',
146
176
  width: '100%',
147
- padding: '0.5rem 1rem',
148
- fontSize: '0.875rem',
177
+ padding: '0.375rem 0.625rem',
178
+ fontSize: '0.8125rem',
149
179
  textAlign: 'left',
150
- color: copied === style ? '#059669' : '#374151',
180
+ color: copied === style ? '#059669' : 'var(--body, #374151)',
151
181
  backgroundColor: copied === style ? '#ecfdf5' : 'transparent',
152
182
  border: 'none',
183
+ borderRadius: '0.25rem',
153
184
  cursor: 'pointer',
154
185
  }}
155
186
  onMouseEnter={(e) => {
156
187
  if (copied !== style) {
157
- e.target.style.backgroundColor = '#f3f4f6'
188
+ e.target.style.backgroundColor = 'var(--card, #f3f4f6)'
158
189
  }
159
190
  }}
160
191
  onMouseLeave={(e) => {
@@ -163,7 +194,9 @@ export function CiteButton({
163
194
  }
164
195
  }}
165
196
  >
166
- {copied === style ? '✓ Copied!' : styleLabels[style] || style.toUpperCase()}
197
+ {copied === style
198
+ ? '✓ Copied!'
199
+ : styleLabels[style] || style.toUpperCase()}
167
200
  </button>
168
201
  ))}
169
202
  </div>
@@ -50,6 +50,12 @@ export function DoiLink({
50
50
  case 'link':
51
51
  displayText = doiUrl
52
52
  break
53
+ case 'label':
54
+ displayText = 'DOI'
55
+ break
56
+ case 'icon':
57
+ displayText = null
58
+ break
53
59
  case 'full':
54
60
  default:
55
61
  displayText = `DOI: ${normalizedDoi}`
@@ -61,35 +67,35 @@ export function DoiLink({
61
67
  target="_blank"
62
68
  rel="noopener noreferrer"
63
69
  className={className}
70
+ title={`DOI: ${normalizedDoi}`}
64
71
  style={{
65
- color: '#2563eb',
72
+ color: 'var(--subtle, #6b7280)',
66
73
  textDecoration: 'none',
67
74
  display: 'inline-flex',
68
75
  alignItems: 'center',
69
76
  gap: '0.25rem',
77
+ fontSize: '0.8125rem',
78
+ transition: 'color 0.15s',
70
79
  }}
80
+ onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--primary, #2563eb)' }}
81
+ onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--subtle, #6b7280)' }}
71
82
  {...props}
72
83
  >
73
- {displayText}
74
- {showIcon && (
75
- <svg
76
- style={{
77
- width: '0.875rem',
78
- height: '0.875rem',
79
- opacity: 0.7,
80
- }}
81
- fill="none"
82
- stroke="currentColor"
83
- viewBox="0 0 24 24"
84
- >
85
- <path
86
- strokeLinecap="round"
87
- strokeLinejoin="round"
88
- strokeWidth={2}
89
- d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
90
- />
91
- </svg>
92
- )}
84
+ {/* DOI icon — external link */}
85
+ <svg
86
+ style={{ width: '0.875rem', height: '0.875rem', flexShrink: 0 }}
87
+ fill="none"
88
+ stroke="currentColor"
89
+ viewBox="0 0 24 24"
90
+ >
91
+ <path
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ strokeWidth={1.5}
95
+ d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
96
+ />
97
+ </svg>
98
+ {displayText && <span>{displayText}</span>}
93
99
  </a>
94
100
  )
95
101
  }
package/src/index.js CHANGED
@@ -4,21 +4,27 @@
4
4
  * Provides math rendering, bibliographic references, and inline citations
5
5
  * for academic and scientific content.
6
6
  *
7
+ * Bibliography formatting is powered by @citestyle/* compiled styles,
8
+ * giving access to 6 citation styles with structured HTML output,
9
+ * year-suffix disambiguation, and cite collapsing.
10
+ *
7
11
  * @module @uniweb/scholar
8
12
  */
9
13
 
10
14
  // Math components
11
15
  export { Math, Equation, EquationRef, loadKatex } from './math/index.js'
12
16
 
13
- // Bibliography utilities
17
+ // Bibliography — formatting, parsing, normalization
14
18
  export {
15
19
  formatReference,
16
- formatAuthors,
20
+ getAvailableStyles,
17
21
  parseBibtex,
18
22
  exportBibtex,
23
+ toCslJson,
24
+ fromCslJson,
19
25
  } from './bibliography/index.js'
20
26
 
21
- // Citation system
27
+ // Citation system — registry-powered
22
28
  export {
23
29
  CitationProvider,
24
30
  Citation,
@@ -1,352 +0,0 @@
1
- /**
2
- * APA 7th Edition Reference Formatter
3
- *
4
- * Formats references according to APA 7th edition guidelines.
5
- *
6
- * @module @uniweb/scholar/bibliography/formatters/apa
7
- */
8
-
9
- import { formatApaAuthors, parseAuthorString } from '../utils/authors.js'
10
- import { formatApaDate, getYear } from '../utils/dates.js'
11
-
12
- /**
13
- * Format a journal article in APA style
14
- *
15
- * @param {Object} pub - Publication data
16
- * @returns {string}
17
- */
18
- function formatArticle(pub) {
19
- const parts = []
20
-
21
- // Authors
22
- const authors = Array.isArray(pub.authors)
23
- ? pub.authors
24
- : parseAuthorString(pub.authors || pub.author)
25
- if (authors.length) {
26
- parts.push(formatApaAuthors(authors))
27
- }
28
-
29
- // Year
30
- const year = getYear(pub.year || pub.date)
31
- parts.push(`(${year || 'n.d.'}).`)
32
-
33
- // Title
34
- if (pub.title) {
35
- parts.push(`${pub.title}.`)
36
- }
37
-
38
- // Journal (italicized in actual rendering)
39
- if (pub.journal) {
40
- let journalPart = pub.journal
41
- if (pub.volume) {
42
- journalPart += `, ${pub.volume}`
43
- if (pub.issue) {
44
- journalPart += `(${pub.issue})`
45
- }
46
- }
47
- if (pub.pages) {
48
- journalPart += `, ${pub.pages}`
49
- }
50
- parts.push(`${journalPart}.`)
51
- }
52
-
53
- // DOI
54
- if (pub.doi) {
55
- const doi = pub.doi.startsWith('http')
56
- ? pub.doi
57
- : `https://doi.org/${pub.doi}`
58
- parts.push(doi)
59
- }
60
-
61
- return parts.join(' ')
62
- }
63
-
64
- /**
65
- * Format a book in APA style
66
- *
67
- * @param {Object} pub - Publication data
68
- * @returns {string}
69
- */
70
- function formatBook(pub) {
71
- const parts = []
72
-
73
- // Authors or editors
74
- const authors = Array.isArray(pub.authors)
75
- ? pub.authors
76
- : parseAuthorString(pub.authors || pub.author)
77
- const editors = Array.isArray(pub.editors)
78
- ? pub.editors
79
- : parseAuthorString(pub.editors || pub.editor)
80
-
81
- if (authors.length) {
82
- parts.push(formatApaAuthors(authors))
83
- } else if (editors.length) {
84
- parts.push(`${formatApaAuthors(editors)} (Ed${editors.length > 1 ? 's' : ''}).`)
85
- }
86
-
87
- // Year
88
- const year = getYear(pub.year || pub.date)
89
- parts.push(`(${year || 'n.d.'}).`)
90
-
91
- // Title (italicized in actual rendering)
92
- if (pub.title) {
93
- let title = pub.title
94
- if (pub.edition && pub.edition !== '1') {
95
- title += ` (${pub.edition} ed.)`
96
- }
97
- parts.push(`${title}.`)
98
- }
99
-
100
- // Publisher
101
- if (pub.publisher) {
102
- parts.push(`${pub.publisher}.`)
103
- }
104
-
105
- // DOI or URL
106
- if (pub.doi) {
107
- const doi = pub.doi.startsWith('http')
108
- ? pub.doi
109
- : `https://doi.org/${pub.doi}`
110
- parts.push(doi)
111
- } else if (pub.url) {
112
- parts.push(pub.url)
113
- }
114
-
115
- return parts.join(' ')
116
- }
117
-
118
- /**
119
- * Format a book chapter in APA style
120
- *
121
- * @param {Object} pub - Publication data
122
- * @returns {string}
123
- */
124
- function formatInBook(pub) {
125
- const parts = []
126
-
127
- // Chapter authors
128
- const authors = Array.isArray(pub.authors)
129
- ? pub.authors
130
- : parseAuthorString(pub.authors || pub.author)
131
- if (authors.length) {
132
- parts.push(formatApaAuthors(authors))
133
- }
134
-
135
- // Year
136
- const year = getYear(pub.year || pub.date)
137
- parts.push(`(${year || 'n.d.'}).`)
138
-
139
- // Chapter title
140
- if (pub.title) {
141
- parts.push(`${pub.title}.`)
142
- }
143
-
144
- // Book editors and title
145
- const editors = Array.isArray(pub.editors)
146
- ? pub.editors
147
- : parseAuthorString(pub.editors || pub.editor)
148
- if (editors.length || pub.bookTitle) {
149
- let bookPart = 'In'
150
- if (editors.length) {
151
- bookPart += ` ${formatApaAuthors(editors)} (Ed${editors.length > 1 ? 's' : ''}),`
152
- }
153
- if (pub.bookTitle) {
154
- bookPart += ` ${pub.bookTitle}`
155
- }
156
- if (pub.pages) {
157
- bookPart += ` (pp. ${pub.pages})`
158
- }
159
- parts.push(`${bookPart}.`)
160
- }
161
-
162
- // Publisher
163
- if (pub.publisher) {
164
- parts.push(`${pub.publisher}.`)
165
- }
166
-
167
- // DOI
168
- if (pub.doi) {
169
- const doi = pub.doi.startsWith('http')
170
- ? pub.doi
171
- : `https://doi.org/${pub.doi}`
172
- parts.push(doi)
173
- }
174
-
175
- return parts.join(' ')
176
- }
177
-
178
- /**
179
- * Format conference proceedings in APA style
180
- *
181
- * @param {Object} pub - Publication data
182
- * @returns {string}
183
- */
184
- function formatConference(pub) {
185
- const parts = []
186
-
187
- // Authors
188
- const authors = Array.isArray(pub.authors)
189
- ? pub.authors
190
- : parseAuthorString(pub.authors || pub.author)
191
- if (authors.length) {
192
- parts.push(formatApaAuthors(authors))
193
- }
194
-
195
- // Year
196
- const year = getYear(pub.year || pub.date)
197
- parts.push(`(${year || 'n.d.'}).`)
198
-
199
- // Title
200
- if (pub.title) {
201
- parts.push(`${pub.title}.`)
202
- }
203
-
204
- // Conference/Proceedings
205
- if (pub.booktitle || pub.conference) {
206
- let confPart = `In ${pub.booktitle || pub.conference}`
207
- if (pub.pages) {
208
- confPart += ` (pp. ${pub.pages})`
209
- }
210
- parts.push(`${confPart}.`)
211
- }
212
-
213
- // Publisher or location
214
- if (pub.publisher) {
215
- parts.push(`${pub.publisher}.`)
216
- } else if (pub.address || pub.location) {
217
- parts.push(`${pub.address || pub.location}.`)
218
- }
219
-
220
- // DOI
221
- if (pub.doi) {
222
- const doi = pub.doi.startsWith('http')
223
- ? pub.doi
224
- : `https://doi.org/${pub.doi}`
225
- parts.push(doi)
226
- }
227
-
228
- return parts.join(' ')
229
- }
230
-
231
- /**
232
- * Format a website/webpage in APA style
233
- *
234
- * @param {Object} pub - Publication data
235
- * @returns {string}
236
- */
237
- function formatWebsite(pub) {
238
- const parts = []
239
-
240
- // Authors or organization
241
- const authors = Array.isArray(pub.authors)
242
- ? pub.authors
243
- : parseAuthorString(pub.authors || pub.author)
244
- if (authors.length) {
245
- parts.push(formatApaAuthors(authors))
246
- } else if (pub.organization) {
247
- parts.push(pub.organization)
248
- }
249
-
250
- // Date
251
- const date = formatApaDate(pub.date || pub.year)
252
- parts.push(`(${date}).`)
253
-
254
- // Title
255
- if (pub.title) {
256
- parts.push(`${pub.title}.`)
257
- }
258
-
259
- // Site name
260
- if (pub.siteName && pub.siteName !== pub.organization) {
261
- parts.push(`${pub.siteName}.`)
262
- }
263
-
264
- // URL
265
- if (pub.url) {
266
- parts.push(pub.url)
267
- }
268
-
269
- return parts.join(' ')
270
- }
271
-
272
- /**
273
- * Format a thesis/dissertation in APA style
274
- *
275
- * @param {Object} pub - Publication data
276
- * @returns {string}
277
- */
278
- function formatThesis(pub) {
279
- const parts = []
280
-
281
- // Author
282
- const authors = Array.isArray(pub.authors)
283
- ? pub.authors
284
- : parseAuthorString(pub.authors || pub.author)
285
- if (authors.length) {
286
- parts.push(formatApaAuthors(authors))
287
- }
288
-
289
- // Year
290
- const year = getYear(pub.year || pub.date)
291
- parts.push(`(${year || 'n.d.'}).`)
292
-
293
- // Title (italicized)
294
- if (pub.title) {
295
- parts.push(`${pub.title}`)
296
- }
297
-
298
- // Thesis type and institution
299
- const type = pub.type || 'Doctoral dissertation'
300
- if (pub.school || pub.institution) {
301
- parts.push(`[${type}, ${pub.school || pub.institution}].`)
302
- } else {
303
- parts.push(`[${type}].`)
304
- }
305
-
306
- // URL or database
307
- if (pub.url) {
308
- parts.push(pub.url)
309
- }
310
-
311
- return parts.join(' ')
312
- }
313
-
314
- /**
315
- * Format a reference in APA 7th edition style
316
- *
317
- * @param {Object} pub - Publication data
318
- * @param {Object} [options] - Formatting options
319
- * @returns {string}
320
- */
321
- export function formatApa(pub, options = {}) {
322
- const type = (pub.type || pub.entryType || 'article').toLowerCase()
323
-
324
- switch (type) {
325
- case 'article':
326
- case 'journal':
327
- return formatArticle(pub)
328
- case 'book':
329
- return formatBook(pub)
330
- case 'inbook':
331
- case 'incollection':
332
- case 'chapter':
333
- return formatInBook(pub)
334
- case 'inproceedings':
335
- case 'conference':
336
- case 'proceedings':
337
- return formatConference(pub)
338
- case 'webpage':
339
- case 'website':
340
- case 'online':
341
- return formatWebsite(pub)
342
- case 'thesis':
343
- case 'phdthesis':
344
- case 'mastersthesis':
345
- return formatThesis(pub)
346
- default:
347
- // Generic format
348
- return formatArticle(pub)
349
- }
350
- }
351
-
352
- export default formatApa