@uniweb/scholar 0.2.0 → 0.2.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.
@@ -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
@@ -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,31 @@
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
- // Math components
11
- export { Math, Equation, EquationRef, loadKatex } from './math/index.js'
14
+ // Math components — scholar no longer ships a math renderer. Plain
15
+ // LaTeX in markdown (`$x$`, `$$x$$`, fenced ```math) is compiled to
16
+ // MathML by the content pipeline and rendered natively by the browser.
17
+ // <Equation> / <EquationRef> cover numbered cross-referenceable
18
+ // equations, consuming pre-compiled MathML via `content.math`.
19
+ export { Equation, EquationRef } from './math/index.js'
12
20
 
13
- // Bibliography utilities
21
+ // Bibliography — formatting, parsing, normalization
14
22
  export {
15
23
  formatReference,
16
- formatAuthors,
24
+ getAvailableStyles,
17
25
  parseBibtex,
18
26
  exportBibtex,
27
+ toCslJson,
28
+ fromCslJson,
19
29
  } from './bibliography/index.js'
20
30
 
21
- // Citation system
31
+ // Citation system — registry-powered
22
32
  export {
23
33
  CitationProvider,
24
34
  Citation,
@@ -1,31 +1,49 @@
1
1
  /**
2
2
  * Equation Component
3
3
  *
4
- * Renders a numbered equation with an optional label for cross-referencing.
5
- * Equations are numbered sequentially within a CitationProvider context.
4
+ * Renders a numbered display equation from pre-compiled MathML.
5
+ *
6
+ * The MathML is produced by Uniweb's content pipeline at build time
7
+ * (see @uniweb/content-reader/math). This component does no math
8
+ * rendering of its own — it wraps the MathML string with numbering and
9
+ * cross-reference machinery, which means it is fully SSR-safe and adds
10
+ * zero runtime math library cost to sites.
11
+ *
12
+ * Authors write labeled display equations in markdown:
13
+ *
14
+ * ```math:einstein
15
+ * E = mc^2
16
+ * ```
17
+ *
18
+ * A foundation's section component then iterates `content.math` and
19
+ * renders each labeled entry through <Equation>:
20
+ *
21
+ * <EquationProvider>
22
+ * {content.math.map(m => (
23
+ * <Equation key={m.id} id={m.id} mathml={m.mathml} />
24
+ * ))}
25
+ * </EquationProvider>
6
26
  *
7
27
  * @module @uniweb/scholar/math/Equation
8
28
  */
9
29
 
10
- import React, { useEffect, useState, useContext, useId } from 'react'
11
- import { loadKatex } from './katex-loader.js'
30
+ import React, { useContext, useEffect, useId } from 'react'
12
31
 
13
32
  /**
14
- * Context for tracking equation numbers
15
- * Exported for use by EquationRef
33
+ * Context for tracking equation numbers. Exported for use by EquationRef.
16
34
  */
17
35
  export const EquationContext = React.createContext(null)
18
36
 
19
37
  /**
20
- * Provider for equation numbering
21
- * Wrap your content with this to enable automatic equation numbering.
38
+ * Provider for equation numbering. Wrap section content with this to
39
+ * enable automatic sequential numbering across all <Equation> instances.
22
40
  *
23
41
  * @param {Object} props
24
42
  * @param {React.ReactNode} props.children
25
43
  * @param {number} [props.startNumber=1] - Starting equation number
26
44
  */
27
45
  export function EquationProvider({ children, startNumber = 1 }) {
28
- const [equations, setEquations] = useState(new Map())
46
+ const [equations, setEquations] = React.useState(new Map())
29
47
  const counterRef = React.useRef(startNumber)
30
48
 
31
49
  const register = React.useCallback((id) => {
@@ -39,7 +57,7 @@ export function EquationProvider({ children, startNumber = 1 }) {
39
57
 
40
58
  const getNumber = React.useCallback(
41
59
  (id) => equations.get(id),
42
- [equations]
60
+ [equations],
43
61
  )
44
62
 
45
63
  return (
@@ -50,77 +68,35 @@ export function EquationProvider({ children, startNumber = 1 }) {
50
68
  }
51
69
 
52
70
  /**
53
- * Hook to access equation context
71
+ * Hook for foundation code that needs direct access to the numbering
72
+ * context (e.g., to render a list of all equations on a page).
54
73
  */
55
74
  export function useEquations() {
56
75
  return useContext(EquationContext)
57
76
  }
58
77
 
59
78
  /**
60
- * Equation - Numbered display equation with optional label
79
+ * Equation — numbered display equation rendered from pre-compiled MathML.
61
80
  *
62
81
  * @param {Object} props
63
- * @param {string} props.children - LaTeX expression
64
- * @param {string} [props.id] - Unique identifier for cross-referencing
65
- * @param {string} [props.label] - Custom label (defaults to equation number)
66
- * @param {string} [props.className] - Additional CSS classes
67
- *
68
- * @example
69
- * <EquationProvider>
70
- * <Equation id="einstein">E = mc^2</Equation>
71
- * <p>As shown in <EquationRef id="einstein" />, energy equals...</p>
72
- * </EquationProvider>
82
+ * @param {string} [props.id] - Identifier for cross-referencing. If omitted,
83
+ * a stable React id is generated but will not be discoverable by <EquationRef>.
84
+ * @param {string} props.mathml - Pre-compiled MathML HTML string from the
85
+ * content pipeline (e.g., `content.math[i].mathml`).
86
+ * @param {string} [props.label] - Override the auto-number with a custom label.
87
+ * @param {string} [props.className] - Additional CSS classes on the wrapper.
73
88
  */
74
- export function Equation({ children, id, label, className, ...props }) {
75
- const [html, setHtml] = useState(null)
89
+ export function Equation({ id, mathml, label, className, ...props }) {
90
+ const ctx = useContext(EquationContext)
76
91
  const generatedId = useId()
77
92
  const equationId = id || generatedId
78
- const ctx = useContext(EquationContext)
79
-
80
- // Extract LaTeX string from children
81
- const latex = typeof children === 'string' ? children.trim() : ''
82
93
 
83
- // Register equation for numbering
84
94
  useEffect(() => {
85
- if (ctx) {
86
- ctx.register(equationId)
87
- }
95
+ if (ctx) ctx.register(equationId)
88
96
  }, [ctx, equationId])
89
97
 
90
- // Get equation number
91
98
  const number = ctx?.getNumber(equationId)
92
-
93
- // Render LaTeX
94
- useEffect(() => {
95
- if (!latex) return
96
-
97
- let cancelled = false
98
-
99
- async function render() {
100
- const katex = await loadKatex()
101
- if (cancelled || !katex) return
102
-
103
- try {
104
- const rendered = katex.renderToString(latex, {
105
- displayMode: true,
106
- throwOnError: false,
107
- })
108
- if (!cancelled) {
109
- setHtml(rendered)
110
- }
111
- } catch (e) {
112
- console.warn('[Equation] Render error:', e.message)
113
- }
114
- }
115
-
116
- render()
117
-
118
- return () => {
119
- cancelled = true
120
- }
121
- }, [latex])
122
-
123
- const displayLabel = label || (number ? `(${number})` : '')
99
+ const displayLabel = label !== undefined ? label : number ? `(${number})` : ''
124
100
 
125
101
  return (
126
102
  <div
@@ -135,16 +111,10 @@ export function Equation({ children, id, label, className, ...props }) {
135
111
  }}
136
112
  {...props}
137
113
  >
138
- {/* Equation content */}
139
- <div style={{ flex: 1, textAlign: 'center' }}>
140
- {html ? (
141
- <span dangerouslySetInnerHTML={{ __html: html }} />
142
- ) : (
143
- <code>{latex}</code>
144
- )}
145
- </div>
146
-
147
- {/* Equation number */}
114
+ <div
115
+ style={{ flex: 1, textAlign: 'center' }}
116
+ dangerouslySetInnerHTML={{ __html: mathml || '' }}
117
+ />
148
118
  {displayLabel && (
149
119
  <span
150
120
  style={{