@uniweb/kit 0.2.2 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,18 +36,14 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
+ "fuse.js": "^7.0.0",
40
+ "shiki": "^3.0.0",
39
41
  "tailwind-merge": "^2.6.0",
40
42
  "@uniweb/core": "0.2.3"
41
43
  },
42
44
  "peerDependencies": {
43
45
  "react": "^18.0.0 || ^19.0.0",
44
- "react-dom": "^18.0.0 || ^19.0.0",
45
- "fuse.js": "^7.0.0"
46
- },
47
- "peerDependenciesMeta": {
48
- "fuse.js": {
49
- "optional": true
50
- }
46
+ "react-dom": "^18.0.0 || ^19.0.0"
51
47
  },
52
48
  "devDependencies": {
53
49
  "tailwindcss": "^3.4.0"
@@ -12,6 +12,7 @@ import { cn } from '../../utils/index.js'
12
12
  import { SafeHtml } from '../../components/SafeHtml/index.js'
13
13
  import { Image } from '../../components/Image/index.js'
14
14
  import { Media } from '../../components/Media/index.js'
15
+ import { Icon } from '../../components/Icon/index.js'
15
16
  import { Link } from '../../components/Link/index.js'
16
17
  import { Code } from './renderers/Code.jsx'
17
18
  import { Alert } from './renderers/Alert.jsx'
@@ -94,7 +95,80 @@ function RenderNode({ node, ...props }) {
94
95
  const src = attrs?.src || ''
95
96
  const alt = attrs?.alt || ''
96
97
  const caption = attrs?.caption || ''
98
+ const role = attrs?.role
97
99
 
100
+ // Dispatch based on role attribute
101
+ if (role === 'video') {
102
+ // Video content - use Media component
103
+ return (
104
+ <Media
105
+ src={src}
106
+ autoplay={attrs?.autoplay}
107
+ muted={attrs?.muted}
108
+ loop={attrs?.loop}
109
+ controls={attrs?.controls}
110
+ className="my-4 rounded-lg overflow-hidden"
111
+ />
112
+ )
113
+ }
114
+
115
+ if (role === 'document') {
116
+ // Document/file link with optional preview
117
+ const poster = attrs?.poster
118
+ const preview = attrs?.preview
119
+ const filename = alt || src.split('/').pop() || 'Document'
120
+
121
+ return (
122
+ <figure className="my-4">
123
+ <Link
124
+ to={src}
125
+ className="block group border rounded-lg overflow-hidden hover:shadow-md transition-shadow"
126
+ target="_blank"
127
+ >
128
+ {(poster || preview) ? (
129
+ <Image
130
+ src={poster || preview}
131
+ alt={filename}
132
+ className="w-full"
133
+ />
134
+ ) : (
135
+ <div className="flex items-center gap-3 p-4 bg-gray-50">
136
+ <Icon name="download" size="24" className="text-gray-500" />
137
+ <span className="text-blue-600 group-hover:underline">
138
+ {filename}
139
+ </span>
140
+ </div>
141
+ )}
142
+ </Link>
143
+ {caption && (
144
+ <figcaption className="mt-2 text-sm text-gray-500 text-center">
145
+ {caption}
146
+ </figcaption>
147
+ )}
148
+ </figure>
149
+ )
150
+ }
151
+
152
+ if (role === 'icon') {
153
+ // Icon - use Icon component
154
+ // Supports: ![alt](lucide:icon-name){size=24 color=blue}
155
+ // ![alt](icon:/path/to/icon.svg){size=32}
156
+ const size = attrs?.size || '24'
157
+ const iconName = attrs?.name || alt
158
+ const iconColor = attrs?.color
159
+
160
+ return (
161
+ <Icon
162
+ url={src}
163
+ name={iconName}
164
+ size={size}
165
+ color={iconColor}
166
+ className="inline-block"
167
+ />
168
+ )
169
+ }
170
+
171
+ // Default: image or banner - use Image component
98
172
  return (
99
173
  <figure className="my-4">
100
174
  <Image src={src} alt={alt} className="rounded-lg" />
@@ -1,31 +1,173 @@
1
1
  /**
2
2
  * Code Block Renderer
3
3
  *
4
- * Renders syntax-highlighted code blocks.
5
- * Uses CSS classes for highlighting (bring your own Prism/Highlight.js CSS).
4
+ * Renders syntax-highlighted code blocks using Shiki.
5
+ * Shiki is lazy-loaded only when code blocks are actually used,
6
+ * and CSS variables are injected at runtime from theme.code.
6
7
  *
7
8
  * @module @uniweb/kit/Section/renderers/Code
8
9
  */
9
10
 
10
- import React, { useEffect, useRef } from 'react'
11
+ import React, { useEffect, useState, useMemo } from 'react'
11
12
  import { cn } from '../../../utils/index.js'
13
+ import { getUniweb } from '@uniweb/core'
14
+
15
+ // Module-level state to track CSS injection and Shiki loading
16
+ let cssInjected = false
17
+ let shikiInstance = null
18
+ let shikiLoadPromise = null
12
19
 
13
20
  /**
14
- * Attempt to highlight code using Prism if available
21
+ * Map theme.code keys to Shiki CSS variable names
15
22
  */
16
- function highlightCode(code, language, element) {
17
- if (typeof window !== 'undefined' && window.Prism && element) {
23
+ const CSS_VAR_MAP = {
24
+ background: '--shiki-background',
25
+ foreground: '--shiki-foreground',
26
+ keyword: '--shiki-token-keyword',
27
+ string: '--shiki-token-string',
28
+ number: '--shiki-token-constant',
29
+ comment: '--shiki-token-comment',
30
+ function: '--shiki-token-function',
31
+ variable: '--shiki-token-variable',
32
+ operator: '--shiki-token-operator',
33
+ punctuation: '--shiki-token-punctuation',
34
+ type: '--shiki-token-type',
35
+ constant: '--shiki-token-constant',
36
+ property: '--shiki-token-property',
37
+ tag: '--shiki-token-tag',
38
+ attribute: '--shiki-token-attribute',
39
+ lineNumber: '--shiki-line-number',
40
+ selection: '--shiki-selection',
41
+ }
42
+
43
+ /**
44
+ * Inject CSS variables from theme.code into the document
45
+ */
46
+ function injectCodeThemeCSS(codeTheme) {
47
+ if (cssInjected || typeof document === 'undefined') return
48
+
49
+ const styleId = 'uniweb-code-theme'
50
+
51
+ // Check if already injected (e.g., by another component instance)
52
+ if (document.getElementById(styleId)) {
53
+ cssInjected = true
54
+ return
55
+ }
56
+
57
+ // Build CSS variables
58
+ const cssVars = []
59
+ for (const [key, value] of Object.entries(codeTheme || {})) {
60
+ const varName = CSS_VAR_MAP[key]
61
+ if (varName && value) {
62
+ cssVars.push(`${varName}: ${value};`)
63
+ }
64
+ }
65
+
66
+ // Create and inject style element
67
+ const style = document.createElement('style')
68
+ style.id = styleId
69
+ style.textContent = `
70
+ :root {
71
+ ${cssVars.join('\n ')}
72
+ }
73
+
74
+ /* Code block base styles */
75
+ .shiki {
76
+ background-color: var(--shiki-background, #1e1e2e);
77
+ color: var(--shiki-foreground, #cdd6f4);
78
+ padding: 1rem;
79
+ border-radius: 0.5rem;
80
+ overflow-x: auto;
81
+ }
82
+
83
+ /* Ensure proper token colors */
84
+ .shiki span {
85
+ color: var(--shiki-token-foreground, inherit);
86
+ }
87
+
88
+ /* Code element inside shiki */
89
+ .shiki code {
90
+ display: block;
91
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
92
+ }
93
+ `
94
+ document.head.appendChild(style)
95
+ cssInjected = true
96
+ }
97
+
98
+ /**
99
+ * Lazy-load Shiki highlighter
100
+ */
101
+ async function loadShiki() {
102
+ if (shikiInstance) return shikiInstance
103
+ if (shikiLoadPromise) return shikiLoadPromise
104
+
105
+ shikiLoadPromise = (async () => {
18
106
  try {
19
- const grammar = window.Prism.languages[language]
20
- if (grammar) {
21
- element.innerHTML = window.Prism.highlight(code, grammar, language)
22
- return true
107
+ const { createHighlighter } = await import('shiki')
108
+
109
+ // Create highlighter with CSS variables theme
110
+ // Only load common languages initially, others load on-demand
111
+ shikiInstance = await createHighlighter({
112
+ themes: ['css-variables'],
113
+ langs: [
114
+ 'javascript',
115
+ 'typescript',
116
+ 'jsx',
117
+ 'tsx',
118
+ 'json',
119
+ 'html',
120
+ 'css',
121
+ 'markdown',
122
+ 'yaml',
123
+ 'bash',
124
+ 'shell',
125
+ 'python',
126
+ ],
127
+ })
128
+
129
+ return shikiInstance
130
+ } catch (error) {
131
+ console.warn('[Code] Failed to load Shiki:', error)
132
+ shikiLoadPromise = null
133
+ return null
134
+ }
135
+ })()
136
+
137
+ return shikiLoadPromise
138
+ }
139
+
140
+ /**
141
+ * Highlight code using Shiki
142
+ */
143
+ async function highlightCode(code, language, highlighter) {
144
+ if (!highlighter) return null
145
+
146
+ try {
147
+ // Load language if not already loaded
148
+ const loadedLangs = highlighter.getLoadedLanguages()
149
+ const lang = language?.toLowerCase() || 'plaintext'
150
+
151
+ if (!loadedLangs.includes(lang) && lang !== 'plaintext') {
152
+ try {
153
+ await highlighter.loadLanguage(lang)
154
+ } catch {
155
+ // Language not available, fall back to plaintext
156
+ return highlighter.codeToHtml(code, {
157
+ lang: 'plaintext',
158
+ theme: 'css-variables',
159
+ })
23
160
  }
24
- } catch (e) {
25
- console.warn('[Code] Prism highlighting failed:', e)
26
161
  }
162
+
163
+ return highlighter.codeToHtml(code, {
164
+ lang: lang === 'plaintext' ? 'text' : lang,
165
+ theme: 'css-variables',
166
+ })
167
+ } catch (error) {
168
+ console.warn('[Code] Highlighting failed:', error)
169
+ return null
27
170
  }
28
- return false
29
171
  }
30
172
 
31
173
  /**
@@ -37,18 +179,77 @@ function highlightCode(code, language, element) {
37
179
  * @param {string} [props.className] - Additional CSS classes
38
180
  */
39
181
  export function Code({ content, language = 'plaintext', className, ...props }) {
40
- const codeRef = useRef(null)
182
+ const [highlightedHtml, setHighlightedHtml] = useState(null)
183
+
184
+ // Get theme from website context (getUniweb is a regular function, not a hook)
185
+ const codeTheme = useMemo(() => {
186
+ try {
187
+ const uniweb = getUniweb()
188
+ return uniweb?.activeWebsite?.themeData?.code
189
+ } catch {
190
+ // Not in runtime context (e.g., storybook), use defaults
191
+ return null
192
+ }
193
+ }, [])
41
194
 
42
195
  // Normalize language
43
- const lang = language?.toLowerCase() || 'plaintext'
196
+ const lang = useMemo(() => {
197
+ const l = language?.toLowerCase() || 'plaintext'
198
+ // Common aliases
199
+ const aliases = {
200
+ js: 'javascript',
201
+ ts: 'typescript',
202
+ sh: 'bash',
203
+ yml: 'yaml',
204
+ md: 'markdown',
205
+ }
206
+ return aliases[l] || l
207
+ }, [language])
208
+
209
+ // Inject CSS on first render (if in browser)
210
+ useEffect(() => {
211
+ if (typeof document !== 'undefined' && codeTheme) {
212
+ injectCodeThemeCSS(codeTheme)
213
+ }
214
+ }, [codeTheme])
44
215
 
45
- // Try to highlight on mount
216
+ // Load Shiki and highlight code
46
217
  useEffect(() => {
47
- if (codeRef.current && content) {
48
- highlightCode(content, lang, codeRef.current)
218
+ let cancelled = false
219
+
220
+ async function highlight() {
221
+ const highlighter = await loadShiki()
222
+ if (cancelled) return
223
+
224
+ if (highlighter && content) {
225
+ const html = await highlightCode(content, lang, highlighter)
226
+ if (!cancelled) {
227
+ setHighlightedHtml(html)
228
+ }
229
+ }
230
+ }
231
+
232
+ highlight()
233
+
234
+ return () => {
235
+ cancelled = true
49
236
  }
50
237
  }, [content, lang])
51
238
 
239
+ // Render highlighted code or fallback
240
+ if (highlightedHtml) {
241
+ return (
242
+ <div
243
+ className={cn('overflow-x-auto rounded-lg text-sm', className)}
244
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
245
+ {...props}
246
+ />
247
+ )
248
+ }
249
+
250
+ // Fallback: plain code block (shown before Shiki loads or if it fails)
251
+ // No loading indicator - the code content is already visible and readable.
252
+ // When Shiki loads, syntax colors will appear smoothly.
52
253
  return (
53
254
  <pre
54
255
  className={cn(
@@ -57,10 +258,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
57
258
  )}
58
259
  {...props}
59
260
  >
60
- <code
61
- ref={codeRef}
62
- className={`language-${lang} text-gray-100`}
63
- >
261
+ <code className={`language-${lang} text-gray-100`}>
64
262
  {content}
65
263
  </code>
66
264
  </pre>