@uniweb/kit 0.1.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.
Files changed (37) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +345 -0
  3. package/package.json +47 -0
  4. package/src/components/Asset/Asset.jsx +161 -0
  5. package/src/components/Asset/index.js +1 -0
  6. package/src/components/Disclaimer/Disclaimer.jsx +198 -0
  7. package/src/components/Disclaimer/index.js +1 -0
  8. package/src/components/FileLogo/FileLogo.jsx +148 -0
  9. package/src/components/FileLogo/index.js +1 -0
  10. package/src/components/Icon/Icon.jsx +214 -0
  11. package/src/components/Icon/index.js +1 -0
  12. package/src/components/Image/Image.jsx +194 -0
  13. package/src/components/Image/index.js +1 -0
  14. package/src/components/Link/Link.jsx +261 -0
  15. package/src/components/Link/index.js +1 -0
  16. package/src/components/Media/Media.jsx +322 -0
  17. package/src/components/Media/index.js +1 -0
  18. package/src/components/MediaIcon/MediaIcon.jsx +95 -0
  19. package/src/components/MediaIcon/index.js +1 -0
  20. package/src/components/SafeHtml/SafeHtml.jsx +93 -0
  21. package/src/components/SafeHtml/index.js +1 -0
  22. package/src/components/Section/Render.jsx +245 -0
  23. package/src/components/Section/Section.jsx +131 -0
  24. package/src/components/Section/index.js +3 -0
  25. package/src/components/Section/renderers/Alert.jsx +101 -0
  26. package/src/components/Section/renderers/Code.jsx +70 -0
  27. package/src/components/Section/renderers/Details.jsx +77 -0
  28. package/src/components/Section/renderers/Divider.jsx +42 -0
  29. package/src/components/Section/renderers/Table.jsx +55 -0
  30. package/src/components/Section/renderers/index.js +11 -0
  31. package/src/components/Text/Text.jsx +207 -0
  32. package/src/components/Text/index.js +14 -0
  33. package/src/hooks/index.js +1 -0
  34. package/src/hooks/useWebsite.js +77 -0
  35. package/src/index.js +69 -0
  36. package/src/styles/index.css +8 -0
  37. package/src/utils/index.js +104 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Icon Component
3
+ *
4
+ * Renders SVG icons from various sources:
5
+ * - Direct SVG content
6
+ * - URL to SVG file
7
+ * - Built-in icons
8
+ *
9
+ * @module @uniweb/kit/Icon
10
+ */
11
+
12
+ import React, { useState, useEffect, useMemo } from 'react'
13
+ import { cn } from '../../utils/index.js'
14
+
15
+ /**
16
+ * Built-in demo icons (simple SVG paths)
17
+ */
18
+ const BUILT_IN_ICONS = {
19
+ check: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>',
20
+ alert: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>',
21
+ user: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>',
22
+ heart: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>',
23
+ settings: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
24
+ star: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>',
25
+ close: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>',
26
+ menu: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>',
27
+ chevronDown: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>',
28
+ chevronRight: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>',
29
+ externalLink: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>',
30
+ download: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>',
31
+ play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>'
32
+ }
33
+
34
+ /**
35
+ * Parse SVG content and extract the inner elements
36
+ * @param {string} svgContent - Raw SVG string
37
+ * @returns {Object} { viewBox, content, width, height }
38
+ */
39
+ function parseSvg(svgContent) {
40
+ if (!svgContent) return null
41
+
42
+ try {
43
+ const parser = new DOMParser()
44
+ const doc = parser.parseFromString(svgContent, 'image/svg+xml')
45
+ const svg = doc.querySelector('svg')
46
+
47
+ if (!svg) return null
48
+
49
+ const viewBox = svg.getAttribute('viewBox') || '0 0 24 24'
50
+ const width = svg.getAttribute('width')
51
+ const height = svg.getAttribute('height')
52
+
53
+ // Get inner content
54
+ const content = svg.innerHTML
55
+
56
+ return { viewBox, content, width, height }
57
+ } catch (error) {
58
+ console.warn('[Icon] Error parsing SVG:', error)
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Icon - SVG icon component
65
+ *
66
+ * @param {Object} props
67
+ * @param {string} [props.name] - Built-in icon name or custom name
68
+ * @param {string} [props.svg] - Direct SVG content
69
+ * @param {string} [props.url] - URL to fetch SVG from
70
+ * @param {string} [props.size='24'] - Icon size in pixels
71
+ * @param {string} [props.color] - Icon color (defaults to currentColor)
72
+ * @param {boolean} [props.preserveColors=false] - Keep original SVG colors
73
+ * @param {string} [props.className] - Additional CSS classes
74
+ * @param {React.ReactNode} [props.loadingComponent] - Custom loading UI
75
+ * @param {React.ReactNode} [props.errorComponent] - Custom error UI
76
+ *
77
+ * @example
78
+ * // Built-in icon
79
+ * <Icon name="check" size="20" color="green" />
80
+ *
81
+ * @example
82
+ * // From URL
83
+ * <Icon url="/icons/custom.svg" size="32" />
84
+ *
85
+ * @example
86
+ * // Direct SVG content
87
+ * <Icon svg="<svg>...</svg>" />
88
+ */
89
+ export function Icon({
90
+ name,
91
+ svg,
92
+ url,
93
+ icon, // Legacy prop - can be string (URL) or object
94
+ size = '24',
95
+ color,
96
+ preserveColors = false,
97
+ className,
98
+ loadingComponent,
99
+ errorComponent,
100
+ ...props
101
+ }) {
102
+ const [fetchedSvg, setFetchedSvg] = useState(null)
103
+ const [loading, setLoading] = useState(false)
104
+ const [error, setError] = useState(false)
105
+
106
+ // Handle legacy icon prop
107
+ const iconUrl = url || (typeof icon === 'string' ? icon : icon?.url)
108
+ const iconSvg = svg || (typeof icon === 'object' ? icon.svg : null)
109
+ const iconName = name || (typeof icon === 'object' ? icon.name : null)
110
+
111
+ // Fetch SVG from URL
112
+ useEffect(() => {
113
+ if (!iconUrl) return
114
+
115
+ setLoading(true)
116
+ setError(false)
117
+
118
+ fetch(iconUrl)
119
+ .then((res) => {
120
+ if (!res.ok) throw new Error('Failed to fetch icon')
121
+ return res.text()
122
+ })
123
+ .then((svgText) => {
124
+ setFetchedSvg(svgText)
125
+ setLoading(false)
126
+ })
127
+ .catch((err) => {
128
+ console.warn('[Icon] Error fetching:', err)
129
+ setError(true)
130
+ setLoading(false)
131
+ })
132
+ }, [iconUrl])
133
+
134
+ // Determine the SVG content to render
135
+ const svgData = useMemo(() => {
136
+ // Priority: direct SVG > fetched SVG > built-in
137
+ if (iconSvg) {
138
+ return parseSvg(iconSvg)
139
+ }
140
+
141
+ if (fetchedSvg) {
142
+ return parseSvg(fetchedSvg)
143
+ }
144
+
145
+ // Built-in icons
146
+ const builtInName = iconName || name
147
+ if (builtInName && BUILT_IN_ICONS[builtInName]) {
148
+ return {
149
+ viewBox: '0 0 24 24',
150
+ content: BUILT_IN_ICONS[builtInName],
151
+ isBuiltIn: true
152
+ }
153
+ }
154
+
155
+ return null
156
+ }, [iconSvg, fetchedSvg, iconName, name])
157
+
158
+ // Loading state
159
+ if (loading) {
160
+ return loadingComponent || (
161
+ <span
162
+ className={cn('inline-block animate-pulse bg-gray-200 rounded', className)}
163
+ style={{ width: size, height: size }}
164
+ />
165
+ )
166
+ }
167
+
168
+ // Error state
169
+ if (error) {
170
+ return errorComponent || null
171
+ }
172
+
173
+ // No content
174
+ if (!svgData) {
175
+ // Fallback: render URL as img if we have a URL but couldn't parse SVG
176
+ if (iconUrl) {
177
+ return (
178
+ <img
179
+ src={iconUrl}
180
+ alt=""
181
+ width={size}
182
+ height={size}
183
+ className={className}
184
+ aria-hidden="true"
185
+ {...props}
186
+ />
187
+ )
188
+ }
189
+ return null
190
+ }
191
+
192
+ // Build style
193
+ const style = {
194
+ width: size,
195
+ height: size,
196
+ ...(color && !preserveColors ? { color } : {})
197
+ }
198
+
199
+ return (
200
+ <svg
201
+ viewBox={svgData.viewBox}
202
+ fill={svgData.isBuiltIn ? 'none' : (preserveColors ? undefined : 'currentColor')}
203
+ stroke={svgData.isBuiltIn ? 'currentColor' : undefined}
204
+ className={cn('inline-block', className)}
205
+ style={style}
206
+ role="img"
207
+ aria-hidden="true"
208
+ dangerouslySetInnerHTML={{ __html: svgData.content }}
209
+ {...props}
210
+ />
211
+ )
212
+ }
213
+
214
+ export default Icon
@@ -0,0 +1 @@
1
+ export { Icon, default } from './Icon.jsx'
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Image Component
3
+ *
4
+ * Versatile image component supporting:
5
+ * - Profile avatars and banners
6
+ * - Asset-based images
7
+ * - Direct URLs
8
+ * - CSS filters
9
+ * - Responsive sizing
10
+ *
11
+ * @module @uniweb/kit/Image
12
+ */
13
+
14
+ import React, { useState, useCallback } from 'react'
15
+ import { Link } from '../Link/index.js'
16
+ import { cn } from '../../utils/index.js'
17
+
18
+ /**
19
+ * Size presets for images
20
+ */
21
+ const SIZE_CLASSES = {
22
+ xs: 'w-8 h-8',
23
+ sm: 'w-12 h-12',
24
+ md: 'w-16 h-16',
25
+ lg: 'w-24 h-24',
26
+ xl: 'w-32 h-32',
27
+ '2xl': 'w-48 h-48',
28
+ full: 'w-full h-full'
29
+ }
30
+
31
+ /**
32
+ * Build CSS filter string from filter object
33
+ * @param {Object} filter - Filter settings
34
+ * @returns {string} CSS filter value
35
+ */
36
+ function buildFilterStyle(filter) {
37
+ if (!filter || typeof filter !== 'object') return undefined
38
+
39
+ const filters = []
40
+
41
+ if (filter.blur) filters.push(`blur(${filter.blur}px)`)
42
+ if (filter.brightness) filters.push(`brightness(${filter.brightness}%)`)
43
+ if (filter.contrast) filters.push(`contrast(${filter.contrast}%)`)
44
+ if (filter.grayscale) filters.push(`grayscale(${filter.grayscale}%)`)
45
+ if (filter.saturate) filters.push(`saturate(${filter.saturate}%)`)
46
+ if (filter.sepia) filters.push(`sepia(${filter.sepia}%)`)
47
+
48
+ return filters.length > 0 ? filters.join(' ') : undefined
49
+ }
50
+
51
+ /**
52
+ * Image - Versatile image component
53
+ *
54
+ * @param {Object} props
55
+ * @param {Object} [props.profile] - Profile object for avatar/banner images
56
+ * @param {string} [props.type] - Image type: 'avatar', 'banner', or 'image'
57
+ * @param {string} [props.size] - Size preset: 'xs', 'sm', 'md', 'lg', 'xl', '2xl', 'full'
58
+ * @param {string|Object} [props.value] - Asset identifier or object
59
+ * @param {string} [props.src] - Direct image URL
60
+ * @param {string} [props.url] - Direct image URL (alias)
61
+ * @param {string} [props.alt] - Alt text for accessibility
62
+ * @param {string} [props.href] - Make image a clickable link
63
+ * @param {boolean|string} [props.rounded] - true for full rounding, or custom class
64
+ * @param {Object} [props.filter] - CSS filter settings
65
+ * @param {string} [props.loading='lazy'] - Loading strategy
66
+ * @param {string} [props.className] - Additional CSS classes
67
+ * @param {boolean} [props.ariaHidden] - Hide from screen readers
68
+ * @param {Function} [props.onError] - Error callback
69
+ * @param {Function} [props.onLoad] - Load callback
70
+ *
71
+ * @example
72
+ * // Direct URL
73
+ * <Image src="/images/hero.jpg" alt="Hero image" />
74
+ *
75
+ * @example
76
+ * // Profile avatar
77
+ * <Image profile={profile} type="avatar" size="lg" rounded />
78
+ *
79
+ * @example
80
+ * // With filters
81
+ * <Image src="/photo.jpg" filter={{ grayscale: 100, brightness: 110 }} />
82
+ *
83
+ * @example
84
+ * // Clickable image
85
+ * <Image src="/logo.png" href="/about" alt="Company logo" />
86
+ */
87
+ export function Image({
88
+ profile,
89
+ type,
90
+ size,
91
+ value,
92
+ src,
93
+ url,
94
+ alt = '',
95
+ href,
96
+ rounded,
97
+ filter,
98
+ loading = 'lazy',
99
+ className,
100
+ ariaHidden,
101
+ onError,
102
+ onLoad,
103
+ ...props
104
+ }) {
105
+ const [hasError, setHasError] = useState(false)
106
+ const [imageSrc, setImageSrc] = useState(null)
107
+
108
+ // Determine the image source
109
+ let resolvedSrc = src || url || ''
110
+ let resolvedAlt = alt
111
+
112
+ // Handle profile-based images
113
+ if (profile && type) {
114
+ if (type === 'avatar' || type === 'banner') {
115
+ // Use profile methods if available
116
+ if (typeof profile.getImageInfo === 'function') {
117
+ const imageInfo = profile.getImageInfo(type, size)
118
+ resolvedSrc = imageInfo?.url || resolvedSrc
119
+ resolvedAlt = imageInfo?.alt || resolvedAlt
120
+ }
121
+ } else if (value && typeof profile.getAssetInfo === 'function') {
122
+ const assetInfo = profile.getAssetInfo(value, true, alt)
123
+ resolvedSrc = assetInfo?.src || resolvedSrc
124
+ resolvedAlt = assetInfo?.alt || resolvedAlt
125
+ }
126
+ }
127
+
128
+ // Handle value as direct source
129
+ if (!resolvedSrc && value) {
130
+ if (typeof value === 'string') {
131
+ resolvedSrc = value
132
+ } else if (value.url || value.src) {
133
+ resolvedSrc = value.url || value.src
134
+ resolvedAlt = value.alt || resolvedAlt
135
+ }
136
+ }
137
+
138
+ // Build classes
139
+ const sizeClass = size && SIZE_CLASSES[size]
140
+ const roundedClass = rounded === true ? 'rounded-full' : (typeof rounded === 'string' ? rounded : '')
141
+
142
+ const imageClasses = cn(
143
+ 'object-cover',
144
+ sizeClass,
145
+ roundedClass,
146
+ className
147
+ )
148
+
149
+ // Build filter style
150
+ const filterStyle = buildFilterStyle(filter)
151
+
152
+ // Handle error
153
+ const handleError = useCallback((e) => {
154
+ setHasError(true)
155
+ onError?.(e)
156
+ }, [onError])
157
+
158
+ // Handle load
159
+ const handleLoad = useCallback((e) => {
160
+ onLoad?.(e)
161
+ }, [onLoad])
162
+
163
+ // Don't render if no source or error
164
+ if (!resolvedSrc || hasError) {
165
+ return null
166
+ }
167
+
168
+ const imageElement = (
169
+ <img
170
+ src={resolvedSrc}
171
+ alt={resolvedAlt}
172
+ loading={loading}
173
+ className={imageClasses}
174
+ style={filterStyle ? { filter: filterStyle } : undefined}
175
+ onError={handleError}
176
+ onLoad={handleLoad}
177
+ aria-hidden={ariaHidden}
178
+ {...props}
179
+ />
180
+ )
181
+
182
+ // Wrap in link if href provided
183
+ if (href) {
184
+ return (
185
+ <Link to={href} className="inline-block">
186
+ {imageElement}
187
+ </Link>
188
+ )
189
+ }
190
+
191
+ return imageElement
192
+ }
193
+
194
+ export default Image
@@ -0,0 +1 @@
1
+ export { Image, default } from './Image.jsx'
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Link Component
3
+ *
4
+ * A smart link wrapper that handles:
5
+ * - Internal navigation via React Router
6
+ * - External links with appropriate attributes
7
+ * - File downloads
8
+ * - Auto-generated accessible titles
9
+ *
10
+ * @module @uniweb/kit/Link
11
+ */
12
+
13
+ import React from 'react'
14
+ import { useWebsite } from '../../hooks/useWebsite.js'
15
+ import { isExternalUrl, isFileUrl } from '../../utils/index.js'
16
+
17
+ /**
18
+ * Social media platforms for auto-generating link titles
19
+ */
20
+ const SOCIAL_PLATFORMS = {
21
+ 'twitter.com': 'Twitter',
22
+ 'x.com': 'X',
23
+ 'facebook.com': 'Facebook',
24
+ 'linkedin.com': 'LinkedIn',
25
+ 'instagram.com': 'Instagram',
26
+ 'youtube.com': 'YouTube',
27
+ 'github.com': 'GitHub',
28
+ 'medium.com': 'Medium'
29
+ }
30
+
31
+ /**
32
+ * Detect if URL is a social media link
33
+ * @param {string} url
34
+ * @returns {string|null} Platform name or null
35
+ */
36
+ function detectSocialPlatform(url) {
37
+ if (!url) return null
38
+
39
+ try {
40
+ const urlObj = new URL(url)
41
+ const host = urlObj.hostname.replace('www.', '')
42
+
43
+ for (const [domain, name] of Object.entries(SOCIAL_PLATFORMS)) {
44
+ if (host.includes(domain)) return name
45
+ }
46
+ } catch {
47
+ // Invalid URL
48
+ }
49
+
50
+ return null
51
+ }
52
+
53
+ /**
54
+ * Generate an accessible title for a link
55
+ * @param {string} href - The link URL
56
+ * @param {Function} localize - Localization function
57
+ * @returns {string}
58
+ */
59
+ function generateTitle(href, localize) {
60
+ if (!href) return ''
61
+
62
+ // Social media links
63
+ const platform = detectSocialPlatform(href)
64
+ if (platform) {
65
+ return localize({
66
+ en: `View on ${platform}`,
67
+ fr: `Voir sur ${platform}`,
68
+ es: `Ver en ${platform}`
69
+ })
70
+ }
71
+
72
+ // Email links
73
+ if (href.startsWith('mailto:')) {
74
+ const email = href.replace('mailto:', '').split('?')[0]
75
+ return localize({
76
+ en: `Send email to ${email}`,
77
+ fr: `Envoyer un e-mail à ${email}`,
78
+ es: `Enviar correo a ${email}`
79
+ })
80
+ }
81
+
82
+ // Phone links
83
+ if (href.startsWith('tel:')) {
84
+ const phone = href.replace('tel:', '')
85
+ return localize({
86
+ en: `Call ${phone}`,
87
+ fr: `Appeler ${phone}`,
88
+ es: `Llamar a ${phone}`
89
+ })
90
+ }
91
+
92
+ // File downloads
93
+ if (isFileUrl(href)) {
94
+ return localize({
95
+ en: 'Download file',
96
+ fr: 'Télécharger le fichier',
97
+ es: 'Descargar archivo'
98
+ })
99
+ }
100
+
101
+ // External links
102
+ if (isExternalUrl(href)) {
103
+ return localize({
104
+ en: 'Open external link',
105
+ fr: 'Ouvrir le lien externe',
106
+ es: 'Abrir enlace externo'
107
+ })
108
+ }
109
+
110
+ // Internal links - humanize the path
111
+ try {
112
+ const url = new URL(href, window.location.origin)
113
+ const path = decodeURIComponent(url.pathname)
114
+ .replace(/^\/+/, '')
115
+ .replace(/[-_]/g, ' ')
116
+ .replace(/\.\w+$/, '')
117
+ .trim()
118
+
119
+ if (path) {
120
+ return localize({
121
+ en: `Go to ${path}`,
122
+ fr: `Aller à ${path}`,
123
+ es: `Ir a ${path}`
124
+ })
125
+ }
126
+ } catch {
127
+ // Invalid URL
128
+ }
129
+
130
+ return ''
131
+ }
132
+
133
+ /**
134
+ * Link - Smart link component for Uniweb foundations
135
+ *
136
+ * @param {Object} props
137
+ * @param {string} [props.to] - Destination URL (alias for href)
138
+ * @param {string} [props.href] - Destination URL
139
+ * @param {string} [props.title] - Custom title/tooltip (auto-generated if not provided)
140
+ * @param {string} [props.target] - Link target (_blank, _self, etc.)
141
+ * @param {string} [props.className] - CSS classes
142
+ * @param {boolean} [props.download] - Force download behavior
143
+ * @param {React.ReactNode} props.children - Link content
144
+ *
145
+ * @example
146
+ * // Internal link
147
+ * <Link to="/about">About Us</Link>
148
+ *
149
+ * @example
150
+ * // External link (automatically opens in new tab)
151
+ * <Link href="https://github.com">GitHub</Link>
152
+ *
153
+ * @example
154
+ * // Download link
155
+ * <Link href="/files/report.pdf" download>Download Report</Link>
156
+ */
157
+ export function Link({
158
+ to,
159
+ href,
160
+ title,
161
+ target,
162
+ download,
163
+ className,
164
+ children,
165
+ ...props
166
+ }) {
167
+ const { localize, makeHref, getRoutingComponents } = useWebsite()
168
+ const RouterLink = getRoutingComponents()?.Link
169
+
170
+ // Normalize href
171
+ let linkHref = href || to || ''
172
+
173
+ // Handle topic: protocol (internal reference)
174
+ if (linkHref.startsWith('topic:')) {
175
+ linkHref = makeHref(linkHref)
176
+ }
177
+
178
+ // Auto-generate title if not provided
179
+ const linkTitle = title || generateTitle(linkHref, localize)
180
+
181
+ // Determine if this should be a download
182
+ const isDownload = download || isFileUrl(linkHref)
183
+
184
+ // Determine if external
185
+ const isExternal = isExternalUrl(linkHref)
186
+
187
+ // File downloads
188
+ if (isDownload) {
189
+ return (
190
+ <a
191
+ href={linkHref}
192
+ download
193
+ target="_blank"
194
+ rel="noopener noreferrer"
195
+ title={linkTitle}
196
+ className={className}
197
+ {...props}
198
+ >
199
+ {children}
200
+ </a>
201
+ )
202
+ }
203
+
204
+ // External links
205
+ if (isExternal) {
206
+ return (
207
+ <a
208
+ href={linkHref}
209
+ target={target || '_blank'}
210
+ rel="noopener noreferrer"
211
+ title={linkTitle}
212
+ className={className}
213
+ {...props}
214
+ >
215
+ {children}
216
+ </a>
217
+ )
218
+ }
219
+
220
+ // Special protocols (mailto:, tel:)
221
+ if (linkHref.startsWith('mailto:') || linkHref.startsWith('tel:')) {
222
+ return (
223
+ <a
224
+ href={linkHref}
225
+ title={linkTitle}
226
+ className={className}
227
+ {...props}
228
+ >
229
+ {children}
230
+ </a>
231
+ )
232
+ }
233
+
234
+ // Internal links - use React Router if available
235
+ if (RouterLink) {
236
+ return (
237
+ <RouterLink
238
+ to={linkHref}
239
+ title={linkTitle}
240
+ className={className}
241
+ {...props}
242
+ >
243
+ {children}
244
+ </RouterLink>
245
+ )
246
+ }
247
+
248
+ // Fallback to regular anchor
249
+ return (
250
+ <a
251
+ href={linkHref}
252
+ title={linkTitle}
253
+ className={className}
254
+ {...props}
255
+ >
256
+ {children}
257
+ </a>
258
+ )
259
+ }
260
+
261
+ export default Link
@@ -0,0 +1 @@
1
+ export { Link, default } from './Link.jsx'