@uniweb/kit 0.1.11 → 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.
- package/README.md +355 -155
- package/package.json +2 -2
- package/src/components/Link/Link.jsx +4 -2
- package/src/hooks/index.js +1 -0
- package/src/hooks/useVersion.js +129 -0
- package/src/index.js +5 -1
- package/src/search/hooks.js +84 -0
- package/src/search/index.js +1 -1
- package/src/utils/index.js +69 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @uniweb/kit
|
|
2
2
|
|
|
3
|
-
Standard component library for Uniweb foundations.
|
|
3
|
+
Standard component library for Uniweb foundations. Tree-shakeable utilities, components, and hooks for building foundation components.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,53 +8,58 @@ Standard component library for Uniweb foundations.
|
|
|
8
8
|
npm install @uniweb/kit
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## Tree-Shaking Benefits
|
|
12
|
+
|
|
13
|
+
Kit is designed to be bundled into your foundation (not externalized like `@uniweb/core`). This means:
|
|
14
|
+
|
|
15
|
+
- **Only what you use is bundled** — Import 3 components? Only those 3 end up in your foundation
|
|
16
|
+
- **No runtime overhead** — Unused code is eliminated at build time
|
|
17
|
+
- **Customizable** — Override or extend any component without carrying dead code
|
|
18
|
+
- **Small foundations** — A minimal foundation using just `Link` and `useWebsite` stays tiny
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// vite.config.js - Kit is bundled, core is external
|
|
22
|
+
export default {
|
|
23
|
+
build: {
|
|
24
|
+
rollupOptions: {
|
|
25
|
+
external: ['react', 'react-dom', 'react-router-dom', '@uniweb/core']
|
|
26
|
+
// Note: @uniweb/kit is NOT in external — it gets tree-shaken
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
11
32
|
## Quick Start
|
|
12
33
|
|
|
13
34
|
```jsx
|
|
14
|
-
import { Link, Image,
|
|
35
|
+
import { Link, Image, useWebsite } from '@uniweb/kit'
|
|
15
36
|
|
|
16
|
-
function Hero() {
|
|
37
|
+
function Hero({ content }) {
|
|
17
38
|
const { localize } = useWebsite()
|
|
18
39
|
|
|
19
40
|
return (
|
|
20
|
-
<
|
|
21
|
-
<Image src=
|
|
22
|
-
<h1>{localize({ en: 'Welcome',
|
|
41
|
+
<div>
|
|
42
|
+
<Image src={content.imgs[0]?.url} alt="Hero" />
|
|
43
|
+
<h1>{localize({ en: 'Welcome', es: 'Bienvenido' })}</h1>
|
|
23
44
|
<Link to="/about">Learn More</Link>
|
|
24
|
-
</
|
|
45
|
+
</div>
|
|
25
46
|
)
|
|
26
47
|
}
|
|
27
48
|
```
|
|
28
49
|
|
|
29
|
-
##
|
|
30
|
-
|
|
31
|
-
Kit provides the standard primitives that make Uniweb foundations work correctly:
|
|
32
|
-
|
|
33
|
-
1. **Handle Uniweb conventions** — Components like `Link` and `Image` understand topic links, locale prefixes, and asset resolution automatically.
|
|
34
|
-
|
|
35
|
-
2. **Runtime integration** — The `useWebsite()` hook gives you access to the website instance, localization functions, and routing utilities.
|
|
36
|
-
|
|
37
|
-
3. **Semantic parser output** — Typography components (`H1`, `P`, `Text`) handle both strings and string arrays correctly, matching the semantic parser's output format.
|
|
38
|
-
|
|
39
|
-
4. **Portability** — Your foundation works identically in development, static builds, and the Uniweb platform.
|
|
50
|
+
## Exports Overview
|
|
40
51
|
|
|
41
|
-
|
|
52
|
+
| Import Path | Purpose |
|
|
53
|
+
|-------------|---------|
|
|
54
|
+
| `@uniweb/kit` | Core components, hooks, and utilities |
|
|
55
|
+
| `@uniweb/kit/styled` | Pre-styled components (Section, SidebarLayout, etc.) |
|
|
56
|
+
| `@uniweb/kit/search` | Search client and hooks (requires Fuse.js) |
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|---------------------|---------------------------|
|
|
45
|
-
| Links and navigation | Custom button styles |
|
|
46
|
-
| Images with filters | Specialized image treatments |
|
|
47
|
-
| Text rendering | Domain-specific formatting |
|
|
48
|
-
| Video embeds | Custom media players |
|
|
49
|
-
| Website/locale access | Business logic |
|
|
50
|
-
|
|
51
|
-
Kit components are designed to be composed and styled, not replaced wholesale. Wrap them, extend them, or use them as building blocks.
|
|
58
|
+
---
|
|
52
59
|
|
|
53
60
|
## Components
|
|
54
61
|
|
|
55
|
-
###
|
|
56
|
-
|
|
57
|
-
#### Link
|
|
62
|
+
### Link
|
|
58
63
|
|
|
59
64
|
Smart link component with routing, downloads, and auto-generated accessible titles.
|
|
60
65
|
|
|
@@ -70,11 +75,11 @@ import { Link } from '@uniweb/kit'
|
|
|
70
75
|
| Prop | Type | Description |
|
|
71
76
|
|------|------|-------------|
|
|
72
77
|
| `to` / `href` | `string` | Destination URL |
|
|
73
|
-
| `title` | `string` | Tooltip (auto-generated if
|
|
78
|
+
| `title` | `string` | Tooltip (auto-generated if omitted) |
|
|
74
79
|
| `target` | `string` | Link target |
|
|
75
80
|
| `download` | `boolean` | Force download behavior |
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
### Image
|
|
78
83
|
|
|
79
84
|
Versatile image component with filters and profile integration.
|
|
80
85
|
|
|
@@ -90,13 +95,13 @@ import { Image } from '@uniweb/kit'
|
|
|
90
95
|
|------|------|-------------|
|
|
91
96
|
| `src` / `url` | `string` | Image URL |
|
|
92
97
|
| `alt` | `string` | Alt text |
|
|
93
|
-
| `size` | `string` |
|
|
98
|
+
| `size` | `string` | Preset: xs, sm, md, lg, xl, 2xl, full |
|
|
94
99
|
| `rounded` | `boolean\|string` | Border radius |
|
|
95
100
|
| `filter` | `object` | CSS filters: blur, brightness, contrast, grayscale, saturate, sepia |
|
|
96
101
|
| `profile` | `object` | Profile for avatar/banner images |
|
|
97
102
|
| `type` | `string` | Image type: avatar, banner |
|
|
98
103
|
|
|
99
|
-
|
|
104
|
+
### SafeHtml
|
|
100
105
|
|
|
101
106
|
Safely render HTML with topic link resolution.
|
|
102
107
|
|
|
@@ -107,7 +112,7 @@ import { SafeHtml } from '@uniweb/kit'
|
|
|
107
112
|
<SafeHtml value='<a href="topic:about">About</a>' />
|
|
108
113
|
```
|
|
109
114
|
|
|
110
|
-
|
|
115
|
+
### Icon
|
|
111
116
|
|
|
112
117
|
SVG icon component with built-in icons and URL loading.
|
|
113
118
|
|
|
@@ -119,28 +124,38 @@ import { Icon } from '@uniweb/kit'
|
|
|
119
124
|
<Icon svg="<svg>...</svg>" />
|
|
120
125
|
```
|
|
121
126
|
|
|
122
|
-
Built-in
|
|
127
|
+
Built-in: check, alert, user, heart, settings, star, close, menu, chevronDown, chevronRight, externalLink, download, play
|
|
123
128
|
|
|
124
|
-
###
|
|
129
|
+
### SocialIcon
|
|
125
130
|
|
|
126
|
-
|
|
131
|
+
Social media platform icons with automatic detection.
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
```jsx
|
|
134
|
+
import { SocialIcon, getSocialPlatform, filterSocialLinks } from '@uniweb/kit'
|
|
135
|
+
|
|
136
|
+
<SocialIcon platform="twitter" size={24} />
|
|
137
|
+
<SocialIcon url="https://twitter.com/example" />
|
|
138
|
+
|
|
139
|
+
// Utilities
|
|
140
|
+
getSocialPlatform('https://linkedin.com/in/user') // 'linkedin'
|
|
141
|
+
filterSocialLinks(links) // Filter to only social links
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Supported: facebook, twitter, x, linkedin, instagram, youtube, github, medium, pinterest, tiktok, discord, mastodon, bluesky, email, phone, orcid, researchgate, googlescholar
|
|
145
|
+
|
|
146
|
+
### Typography
|
|
147
|
+
|
|
148
|
+
Smart typography components for rendering semantic parser output.
|
|
129
149
|
|
|
130
150
|
```jsx
|
|
131
151
|
import { Text, H1, H2, P, PlainText } from '@uniweb/kit'
|
|
132
152
|
|
|
133
|
-
// Using semantic aliases (recommended)
|
|
134
153
|
<H1 text="Main Title" />
|
|
135
154
|
<H2 text={["Multi-line", "Subtitle"]} />
|
|
136
155
|
<P text="A paragraph of content" />
|
|
137
156
|
<P text={["First paragraph", "Second paragraph"]} />
|
|
138
157
|
|
|
139
|
-
//
|
|
140
|
-
<Text text="Hello" as="h1" />
|
|
141
|
-
<Text text={["Line 1", "Line 2"]} as="h2" />
|
|
142
|
-
|
|
143
|
-
// Plain text (HTML tags shown as text)
|
|
158
|
+
// Plain text (HTML shown as text)
|
|
144
159
|
<PlainText text="Show <strong>tags</strong> as text" />
|
|
145
160
|
```
|
|
146
161
|
|
|
@@ -150,19 +165,11 @@ import { Text, H1, H2, P, PlainText } from '@uniweb/kit'
|
|
|
150
165
|
| `as` | `string` | Tag: 'h1'-'h6', 'p', 'div', 'span' |
|
|
151
166
|
| `html` | `boolean` | Render as HTML (default: true) |
|
|
152
167
|
| `lineAs` | `string` | Tag for array items |
|
|
153
|
-
| `className` | `string` | CSS classes |
|
|
154
168
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
**Key behaviors**:
|
|
158
|
-
- Empty strings/arrays return `null` (no empty elements)
|
|
159
|
-
- Headings with arrays: all lines wrapped in single heading tag
|
|
160
|
-
- Paragraphs with arrays: each line gets its own `<p>` tag
|
|
169
|
+
Aliases: `H1`, `H2`, `H3`, `H4`, `H5`, `H6`, `P`, `Span`, `Div`, `PlainText`
|
|
161
170
|
|
|
162
171
|
### Media
|
|
163
172
|
|
|
164
|
-
#### Media
|
|
165
|
-
|
|
166
173
|
Video player for YouTube, Vimeo, and local videos.
|
|
167
174
|
|
|
168
175
|
```jsx
|
|
@@ -173,17 +180,7 @@ import { Media } from '@uniweb/kit'
|
|
|
173
180
|
<Media src="https://youtube.com/..." thumbnail="/poster.jpg" facade />
|
|
174
181
|
```
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
|------|------|-------------|
|
|
178
|
-
| `src` | `string` | Video URL |
|
|
179
|
-
| `thumbnail` | `string` | Poster image URL |
|
|
180
|
-
| `autoplay` | `boolean` | Auto-play video |
|
|
181
|
-
| `muted` | `boolean` | Mute video |
|
|
182
|
-
| `loop` | `boolean` | Loop video |
|
|
183
|
-
| `controls` | `boolean` | Show controls |
|
|
184
|
-
| `facade` | `boolean` | Show thumbnail with play button |
|
|
185
|
-
|
|
186
|
-
#### FileLogo
|
|
183
|
+
### FileLogo
|
|
187
184
|
|
|
188
185
|
File type icons based on filename.
|
|
189
186
|
|
|
@@ -191,143 +188,364 @@ File type icons based on filename.
|
|
|
191
188
|
import { FileLogo } from '@uniweb/kit'
|
|
192
189
|
|
|
193
190
|
<FileLogo filename="report.pdf" size="32" />
|
|
194
|
-
<FileLogo filename="data.xlsx" />
|
|
195
191
|
```
|
|
196
192
|
|
|
197
|
-
|
|
193
|
+
### MediaIcon
|
|
198
194
|
|
|
199
|
-
|
|
195
|
+
Platform icons (YouTube, Vimeo, etc.).
|
|
200
196
|
|
|
201
197
|
```jsx
|
|
202
198
|
import { MediaIcon } from '@uniweb/kit'
|
|
203
199
|
|
|
204
|
-
<MediaIcon type="
|
|
205
|
-
<MediaIcon type="linkedin" className="text-blue-600" />
|
|
200
|
+
<MediaIcon type="youtube" size="24" />
|
|
206
201
|
```
|
|
207
202
|
|
|
208
|
-
|
|
203
|
+
### Asset
|
|
209
204
|
|
|
210
|
-
|
|
205
|
+
File preview with download functionality.
|
|
211
206
|
|
|
212
|
-
|
|
207
|
+
```jsx
|
|
208
|
+
import { Asset } from '@uniweb/kit'
|
|
209
|
+
|
|
210
|
+
<Asset value="document.pdf" profile={profile} />
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
## Hooks
|
|
216
|
+
|
|
217
|
+
### useWebsite
|
|
218
|
+
|
|
219
|
+
Access website instance and utilities.
|
|
215
220
|
|
|
216
221
|
```jsx
|
|
217
|
-
import {
|
|
222
|
+
import { useWebsite } from '@uniweb/kit'
|
|
218
223
|
|
|
219
|
-
|
|
224
|
+
function MyComponent() {
|
|
225
|
+
const {
|
|
226
|
+
website, // Website instance
|
|
227
|
+
localize, // Localize multilingual values
|
|
228
|
+
makeHref, // Transform hrefs (topic:, locale prefixes)
|
|
229
|
+
getLanguage, // Current language code
|
|
230
|
+
getLanguages // Available languages
|
|
231
|
+
} = useWebsite()
|
|
220
232
|
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
<div>Column 2</div>
|
|
224
|
-
</Section>
|
|
233
|
+
return <div>{localize({ en: 'Hello', fr: 'Bonjour' })}</div>
|
|
234
|
+
}
|
|
225
235
|
```
|
|
226
236
|
|
|
227
|
-
|
|
228
|
-
|------|------|-------------|
|
|
229
|
-
| `content` | `object\|array` | Content to render |
|
|
230
|
-
| `block` | `object` | Block object from runtime |
|
|
231
|
-
| `width` | `string` | sm, md, lg, xl, 2xl, full |
|
|
232
|
-
| `columns` | `string` | 1, 2, 3, 4 |
|
|
233
|
-
| `padding` | `string` | none, sm, md, lg, xl |
|
|
237
|
+
### useActiveRoute
|
|
234
238
|
|
|
235
|
-
|
|
239
|
+
Detect active navigation state.
|
|
236
240
|
|
|
237
|
-
|
|
241
|
+
```jsx
|
|
242
|
+
import { useActiveRoute } from '@uniweb/kit'
|
|
243
|
+
|
|
244
|
+
function NavLink({ page }) {
|
|
245
|
+
const { isActive, isActiveOrAncestor } = useActiveRoute()
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<Link
|
|
249
|
+
to={page.route}
|
|
250
|
+
className={isActiveOrAncestor(page) ? 'font-bold' : ''}
|
|
251
|
+
>
|
|
252
|
+
{page.title}
|
|
253
|
+
</Link>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### useScrolled
|
|
259
|
+
|
|
260
|
+
Detect scroll position for sticky headers.
|
|
238
261
|
|
|
239
262
|
```jsx
|
|
240
|
-
import {
|
|
263
|
+
import { useScrolled } from '@uniweb/kit'
|
|
241
264
|
|
|
242
|
-
|
|
265
|
+
function Header() {
|
|
266
|
+
const scrolled = useScrolled(50) // Threshold in pixels
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<header className={scrolled ? 'shadow-md' : ''}>
|
|
270
|
+
...
|
|
271
|
+
</header>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
243
274
|
```
|
|
244
275
|
|
|
245
|
-
|
|
276
|
+
### useMobileMenu
|
|
246
277
|
|
|
247
|
-
|
|
278
|
+
Mobile menu state management.
|
|
248
279
|
|
|
249
280
|
```jsx
|
|
250
|
-
import {
|
|
281
|
+
import { useMobileMenu } from '@uniweb/kit'
|
|
282
|
+
|
|
283
|
+
function Navbar() {
|
|
284
|
+
const { isOpen, toggle, close } = useMobileMenu()
|
|
251
285
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
<button onClick={toggle}>Menu</button>
|
|
289
|
+
{isOpen && <MobileMenu onClose={close} />}
|
|
290
|
+
</>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
257
293
|
```
|
|
258
294
|
|
|
259
|
-
###
|
|
295
|
+
### useAccordion
|
|
260
296
|
|
|
261
|
-
|
|
297
|
+
Accordion/FAQ state management.
|
|
262
298
|
|
|
263
|
-
|
|
299
|
+
```jsx
|
|
300
|
+
import { useAccordion } from '@uniweb/kit'
|
|
301
|
+
|
|
302
|
+
function FAQ({ items }) {
|
|
303
|
+
const { isOpen, toggle } = useAccordion()
|
|
304
|
+
|
|
305
|
+
return items.map((item, i) => (
|
|
306
|
+
<div key={i}>
|
|
307
|
+
<button onClick={() => toggle(i)}>{item.question}</button>
|
|
308
|
+
{isOpen(i) && <p>{item.answer}</p>}
|
|
309
|
+
</div>
|
|
310
|
+
))
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### useInView
|
|
315
|
+
|
|
316
|
+
Viewport intersection detection for lazy loading and animations.
|
|
264
317
|
|
|
265
318
|
```jsx
|
|
266
|
-
import {
|
|
319
|
+
import { useInView, useIsInView } from '@uniweb/kit'
|
|
267
320
|
|
|
268
|
-
|
|
269
|
-
|
|
321
|
+
function AnimatedSection() {
|
|
322
|
+
const { ref, inView } = useInView({ threshold: 0.2, once: true })
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<div ref={ref} className={inView ? 'animate-fade-in' : 'opacity-0'}>
|
|
326
|
+
Content appears when scrolled into view
|
|
327
|
+
</div>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Simple boolean version
|
|
332
|
+
function LazyImage({ src }) {
|
|
333
|
+
const [ref, isInView] = useIsInView()
|
|
334
|
+
return <div ref={ref}>{isInView && <img src={src} />}</div>
|
|
335
|
+
}
|
|
270
336
|
```
|
|
271
337
|
|
|
272
|
-
|
|
338
|
+
### useGridLayout
|
|
273
339
|
|
|
274
|
-
|
|
340
|
+
Responsive grid utilities.
|
|
275
341
|
|
|
276
342
|
```jsx
|
|
277
|
-
import {
|
|
343
|
+
import { useGridLayout, getGridClasses } from '@uniweb/kit'
|
|
278
344
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
345
|
+
function Gallery({ items }) {
|
|
346
|
+
const { columns } = useGridLayout(items.length, { maxColumns: 4 })
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div className={getGridClasses(columns)}>
|
|
350
|
+
{items.map(item => <Card key={item.id} {...item} />)}
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
284
354
|
```
|
|
285
355
|
|
|
286
|
-
|
|
356
|
+
### Theme Hooks
|
|
287
357
|
|
|
288
|
-
|
|
358
|
+
Access site theming data at runtime.
|
|
289
359
|
|
|
290
|
-
|
|
360
|
+
```jsx
|
|
361
|
+
import {
|
|
362
|
+
useThemeData,
|
|
363
|
+
useThemeColor,
|
|
364
|
+
useThemeColorVar,
|
|
365
|
+
useColorContext,
|
|
366
|
+
useAppearance
|
|
367
|
+
} from '@uniweb/kit'
|
|
368
|
+
|
|
369
|
+
function ThemedComponent({ block }) {
|
|
370
|
+
// Full theme access
|
|
371
|
+
const theme = useThemeData()
|
|
372
|
+
const palettes = theme?.getPaletteNames() // ['primary', 'secondary', ...]
|
|
373
|
+
|
|
374
|
+
// Get specific color
|
|
375
|
+
const primaryColor = useThemeColor('primary', 500) // '#3b82f6'
|
|
376
|
+
const primaryVar = useThemeColorVar('primary', 600) // 'var(--primary-600)'
|
|
377
|
+
|
|
378
|
+
// Context-aware (light/medium/dark sections)
|
|
379
|
+
const context = useColorContext(block) // 'light' | 'medium' | 'dark'
|
|
380
|
+
|
|
381
|
+
// Dark mode
|
|
382
|
+
const { scheme, toggle, canToggle } = useAppearance()
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div style={{ color: primaryColor }}>
|
|
386
|
+
{canToggle && (
|
|
387
|
+
<button onClick={toggle}>
|
|
388
|
+
{scheme === 'dark' ? 'Light' : 'Dark'}
|
|
389
|
+
</button>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Search (`@uniweb/kit/search`)
|
|
399
|
+
|
|
400
|
+
Full-text search powered by Fuse.js. Requires `fuse.js` as a peer dependency in your foundation.
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
npm install fuse.js
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### useSearch
|
|
407
|
+
|
|
408
|
+
Main search hook with debouncing and state management.
|
|
291
409
|
|
|
292
410
|
```jsx
|
|
293
|
-
import {
|
|
411
|
+
import { useSearch } from '@uniweb/kit/search'
|
|
294
412
|
|
|
295
|
-
function
|
|
296
|
-
const {
|
|
297
|
-
|
|
298
|
-
localize, // Localize multilingual values
|
|
299
|
-
makeHref, // Transform hrefs
|
|
300
|
-
getLanguage, // Current language
|
|
301
|
-
getLanguages // Available languages
|
|
302
|
-
} = useWebsite()
|
|
413
|
+
function SearchComponent() {
|
|
414
|
+
const { website } = useWebsite()
|
|
415
|
+
const { query, results, isLoading, isEnabled, preload } = useSearch(website)
|
|
303
416
|
|
|
304
|
-
|
|
417
|
+
if (!isEnabled) return null
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<div>
|
|
421
|
+
<input onChange={e => query(e.target.value)} placeholder="Search..." />
|
|
422
|
+
{isLoading && <span>Searching...</span>}
|
|
423
|
+
{results.map(r => (
|
|
424
|
+
<a key={r.id} href={r.href}>{r.title}</a>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### useSearchWithIntent
|
|
432
|
+
|
|
433
|
+
Intent-based preloading — loads search index on hover/focus instead of page load.
|
|
434
|
+
|
|
435
|
+
```jsx
|
|
436
|
+
import { useSearchWithIntent, useSearchShortcut } from '@uniweb/kit/search'
|
|
437
|
+
|
|
438
|
+
function SearchButton({ onClick }) {
|
|
439
|
+
const { website } = useWebsite()
|
|
440
|
+
const { triggerPreload, intentProps } = useSearchWithIntent(website)
|
|
441
|
+
|
|
442
|
+
// Cmd/Ctrl+K shortcut with preload
|
|
443
|
+
useSearchShortcut({
|
|
444
|
+
onOpen: onClick,
|
|
445
|
+
onPreload: triggerPreload,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<button onClick={onClick} {...intentProps}>
|
|
450
|
+
Search
|
|
451
|
+
</button>
|
|
452
|
+
)
|
|
305
453
|
}
|
|
306
454
|
```
|
|
307
455
|
|
|
308
|
-
|
|
456
|
+
This saves bandwidth — the search index only loads when users show intent to search.
|
|
457
|
+
|
|
458
|
+
### useSearchShortcut
|
|
459
|
+
|
|
460
|
+
Keyboard shortcut for opening search.
|
|
461
|
+
|
|
462
|
+
```jsx
|
|
463
|
+
import { useSearchShortcut } from '@uniweb/kit/search'
|
|
464
|
+
|
|
465
|
+
// Simple
|
|
466
|
+
useSearchShortcut(() => setSearchOpen(true))
|
|
467
|
+
|
|
468
|
+
// With preload on shortcut
|
|
469
|
+
useSearchShortcut({
|
|
470
|
+
onOpen: () => setSearchOpen(true),
|
|
471
|
+
onPreload: () => searchClient.preload()
|
|
472
|
+
})
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### createSearchClient
|
|
476
|
+
|
|
477
|
+
Low-level search client for advanced use.
|
|
478
|
+
|
|
479
|
+
```jsx
|
|
480
|
+
import { createSearchClient } from '@uniweb/kit/search'
|
|
481
|
+
|
|
482
|
+
const client = createSearchClient(website, {
|
|
483
|
+
fuseOptions: { threshold: 0.3 },
|
|
484
|
+
defaultLimit: 10
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
// Query
|
|
488
|
+
const results = await client.query('authentication', { limit: 5 })
|
|
489
|
+
|
|
490
|
+
// Preload index
|
|
491
|
+
await client.preload()
|
|
492
|
+
|
|
493
|
+
// Check status
|
|
494
|
+
client.isEnabled()
|
|
495
|
+
client.getIndexUrl()
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## Styled Components (`@uniweb/kit/styled`)
|
|
501
|
+
|
|
502
|
+
Pre-styled components with Tailwind CSS. Import separately to keep core kit dependency-free.
|
|
503
|
+
|
|
504
|
+
```jsx
|
|
505
|
+
import { Section, SidebarLayout, Disclaimer } from '@uniweb/kit/styled'
|
|
506
|
+
|
|
507
|
+
<Section width="lg" padding="md" className="bg-gray-50">
|
|
508
|
+
<h1>Welcome</h1>
|
|
509
|
+
</Section>
|
|
510
|
+
|
|
511
|
+
<SidebarLayout sidebar={<Nav />} sidebarPosition="left">
|
|
512
|
+
<main>Content</main>
|
|
513
|
+
</SidebarLayout>
|
|
514
|
+
|
|
515
|
+
<Disclaimer
|
|
516
|
+
title="Terms of Service"
|
|
517
|
+
content="<p>Please read our terms...</p>"
|
|
518
|
+
triggerText="View Terms"
|
|
519
|
+
/>
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Utilities
|
|
309
525
|
|
|
310
526
|
```jsx
|
|
311
527
|
import { cn, stripTags, isExternalUrl, isFileUrl, detectMediaType } from '@uniweb/kit'
|
|
312
528
|
|
|
313
|
-
// Merge Tailwind classes
|
|
529
|
+
// Merge Tailwind classes (uses tailwind-merge)
|
|
314
530
|
cn('px-4 py-2', 'bg-blue-500', condition && 'opacity-50')
|
|
315
531
|
|
|
316
532
|
// Strip HTML tags
|
|
317
|
-
stripTags('<p>Hello</p>')
|
|
533
|
+
stripTags('<p>Hello</p>') // "Hello"
|
|
318
534
|
|
|
319
535
|
// URL utilities
|
|
320
|
-
isExternalUrl('https://google.com')
|
|
321
|
-
isFileUrl('/files/doc.pdf')
|
|
322
|
-
detectMediaType('https://youtube.com/...')
|
|
536
|
+
isExternalUrl('https://google.com') // true
|
|
537
|
+
isFileUrl('/files/doc.pdf') // true
|
|
538
|
+
detectMediaType('https://youtube.com/...') // 'youtube'
|
|
323
539
|
```
|
|
324
540
|
|
|
541
|
+
---
|
|
542
|
+
|
|
325
543
|
## Architecture
|
|
326
544
|
|
|
327
545
|
```
|
|
328
546
|
┌─────────────────────────────────────────────────────────────┐
|
|
329
547
|
│ Foundation (your code) │
|
|
330
|
-
│ ├── imports @uniweb/kit (bundled
|
|
548
|
+
│ ├── imports @uniweb/kit (bundled, tree-shaken) │
|
|
331
549
|
│ └── @uniweb/core marked as external │
|
|
332
550
|
└─────────────────────────────────────────────────────────────┘
|
|
333
551
|
│
|
|
@@ -340,29 +558,11 @@ detectMediaType('https://youtube.com/...') // 'youtube'
|
|
|
340
558
|
└─────────────────────────────────────────────────────────────┘
|
|
341
559
|
```
|
|
342
560
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
1. **Use kit components** for common UI patterns
|
|
346
|
-
2. **Use hooks** like `useWebsite` for localization and routing
|
|
347
|
-
3. **Don't access `globalThis.uniweb`** directly - use kit's abstractions
|
|
348
|
-
4. **Bundle kit, externalize core** - kit gets tree-shaken into your foundation
|
|
561
|
+
### Why bundle kit but externalize core?
|
|
349
562
|
|
|
350
|
-
|
|
351
|
-
// vite.config.js
|
|
352
|
-
export default {
|
|
353
|
-
build: {
|
|
354
|
-
rollupOptions: {
|
|
355
|
-
// Kit is bundled (tree-shaken), core is external (provided by runtime)
|
|
356
|
-
external: ['react', 'react-dom', 'react-router-dom', '@uniweb/core']
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
```
|
|
563
|
+
- **Kit**: Different foundations may use different subsets of kit. Tree-shaking ensures each foundation only includes what it uses.
|
|
361
564
|
|
|
362
|
-
|
|
363
|
-
- Tree-shake unused kit components
|
|
364
|
-
- Override or extend kit components
|
|
365
|
-
- Bring your own alternative components
|
|
565
|
+
- **Core**: Contains the Website, Page, and Block classes that must be singletons. The runtime provides these — foundations reference them via the external import.
|
|
366
566
|
|
|
367
567
|
## License
|
|
368
568
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"tailwind-merge": "^2.6.0",
|
|
40
|
-
"@uniweb/core": "0.1
|
|
40
|
+
"@uniweb/core": "0.2.1"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -170,8 +170,10 @@ export function Link({
|
|
|
170
170
|
// Normalize href
|
|
171
171
|
let linkHref = href || to || ''
|
|
172
172
|
|
|
173
|
-
// Handle
|
|
174
|
-
|
|
173
|
+
// Handle internal reference protocols
|
|
174
|
+
// - topic: legacy internal reference
|
|
175
|
+
// - page: stable page reference (page:pageId#sectionId)
|
|
176
|
+
if (linkHref.startsWith('topic:') || linkHref.startsWith('page:')) {
|
|
175
177
|
linkHref = makeHref(linkHref)
|
|
176
178
|
}
|
|
177
179
|
|
package/src/hooks/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { useWebsite, default } from './useWebsite.js'
|
|
2
2
|
export { useRouting } from './useRouting.js'
|
|
3
3
|
export { useActiveRoute } from './useActiveRoute.js'
|
|
4
|
+
export { useVersion } from './useVersion.js'
|
|
4
5
|
export { useScrolled } from './useScrolled.js'
|
|
5
6
|
export { useMobileMenu } from './useMobileMenu.js'
|
|
6
7
|
export { useAccordion } from './useAccordion.js'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useVersion Hook
|
|
3
|
+
*
|
|
4
|
+
* Hook for version switching in documentation sites.
|
|
5
|
+
* Provides access to version information and utilities for building
|
|
6
|
+
* version switcher components.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* function VersionSwitcher() {
|
|
10
|
+
* const { isVersioned, currentVersion, versions, getVersionUrl } = useVersion()
|
|
11
|
+
*
|
|
12
|
+
* if (!isVersioned) return null
|
|
13
|
+
*
|
|
14
|
+
* return (
|
|
15
|
+
* <select
|
|
16
|
+
* value={currentVersion?.id}
|
|
17
|
+
* onChange={(e) => navigate(getVersionUrl(e.target.value))}
|
|
18
|
+
* >
|
|
19
|
+
* {versions.map(v => (
|
|
20
|
+
* <option key={v.id} value={v.id}>
|
|
21
|
+
* {v.label} {v.latest ? '(latest)' : ''} {v.deprecated ? '(deprecated)' : ''}
|
|
22
|
+
* </option>
|
|
23
|
+
* ))}
|
|
24
|
+
* </select>
|
|
25
|
+
* )
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useRouting } from './useRouting.js'
|
|
30
|
+
import { useWebsite } from './useWebsite.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hook for version information and switching.
|
|
34
|
+
* Works both at page level (when page has version context) and
|
|
35
|
+
* at site level (checking versioned scopes).
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} options - Optional configuration
|
|
38
|
+
* @param {Page} options.page - Specific page to check (default: active page)
|
|
39
|
+
* @returns {Object} Version utilities
|
|
40
|
+
*/
|
|
41
|
+
export function useVersion(options = {}) {
|
|
42
|
+
const { useLocation } = useRouting()
|
|
43
|
+
const { website } = useWebsite()
|
|
44
|
+
const location = useLocation()
|
|
45
|
+
|
|
46
|
+
const page = options.page || website.activePage
|
|
47
|
+
// Prefer page route for SSG accuracy, fallback to location pathname
|
|
48
|
+
const currentRoute = page?.route || location?.pathname || '/'
|
|
49
|
+
|
|
50
|
+
// Check if the current page/route is within a versioned section
|
|
51
|
+
const isVersioned = page?.isVersioned() || website.isVersionedRoute(currentRoute)
|
|
52
|
+
|
|
53
|
+
// Get version information from page (preferred) or compute from route
|
|
54
|
+
const currentVersion = page?.getVersion() || null
|
|
55
|
+
const versionMeta = page?.versionMeta || website.getVersionMeta(website.getVersionScope(currentRoute))
|
|
56
|
+
const versions = versionMeta?.versions || []
|
|
57
|
+
const latestVersionId = versionMeta?.latestId || null
|
|
58
|
+
|
|
59
|
+
// Find the version scope for the current route
|
|
60
|
+
const versionScope = page?.versionScope || website.getVersionScope(currentRoute)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
/**
|
|
64
|
+
* Whether the current page is within a versioned section
|
|
65
|
+
* @type {boolean}
|
|
66
|
+
*/
|
|
67
|
+
isVersioned,
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Current version info { id, label, latest, deprecated }
|
|
71
|
+
* @type {Object|null}
|
|
72
|
+
*/
|
|
73
|
+
currentVersion,
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* All available versions for this scope
|
|
77
|
+
* @type {Array<{id: string, label: string, latest: boolean, deprecated: boolean}>}
|
|
78
|
+
*/
|
|
79
|
+
versions,
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The ID of the latest version
|
|
83
|
+
* @type {string|null}
|
|
84
|
+
*/
|
|
85
|
+
latestVersionId,
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The route where versioning starts (e.g., '/docs')
|
|
89
|
+
* @type {string|null}
|
|
90
|
+
*/
|
|
91
|
+
versionScope,
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if current version is the latest
|
|
95
|
+
* @type {boolean}
|
|
96
|
+
*/
|
|
97
|
+
isLatestVersion: currentVersion?.latest === true,
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if current version is deprecated
|
|
101
|
+
* @type {boolean}
|
|
102
|
+
*/
|
|
103
|
+
isDeprecatedVersion: currentVersion?.deprecated === true,
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get URL for switching to a different version
|
|
107
|
+
* @param {string} targetVersion - Target version ID (e.g., 'v1')
|
|
108
|
+
* @returns {string|null} Target URL or null if not versioned
|
|
109
|
+
*/
|
|
110
|
+
getVersionUrl: (targetVersion) => {
|
|
111
|
+
if (!isVersioned) return null
|
|
112
|
+
return website.getVersionUrl(targetVersion, currentRoute)
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if site has any versioned content
|
|
117
|
+
* @type {boolean}
|
|
118
|
+
*/
|
|
119
|
+
hasVersionedContent: website.hasVersionedContent(),
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get all versioned scopes in the site
|
|
123
|
+
* @type {Object} Map of scope → { versions, latestId }
|
|
124
|
+
*/
|
|
125
|
+
versionedScopes: website.getVersionedScopes(),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default useVersion
|
package/src/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export {
|
|
|
62
62
|
useWebsite,
|
|
63
63
|
useRouting,
|
|
64
64
|
useActiveRoute,
|
|
65
|
+
useVersion,
|
|
65
66
|
useScrolled,
|
|
66
67
|
useMobileMenu,
|
|
67
68
|
useAccordion,
|
|
@@ -93,5 +94,8 @@ export {
|
|
|
93
94
|
stripTags,
|
|
94
95
|
isExternalUrl,
|
|
95
96
|
isFileUrl,
|
|
96
|
-
detectMediaType
|
|
97
|
+
detectMediaType,
|
|
98
|
+
// Locale utilities
|
|
99
|
+
LOCALE_DISPLAY_NAMES,
|
|
100
|
+
getLocaleLabel
|
|
97
101
|
} from './utils/index.js'
|
package/src/search/hooks.js
CHANGED
|
@@ -236,4 +236,88 @@ export function useSearchIndex(website, options = {}) {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Hook for Cmd/Ctrl+K keyboard shortcut to open search
|
|
241
|
+
*
|
|
242
|
+
* @param {Function|Object} callbacks - Either onOpen function, or { onOpen, onPreload }
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* // Simple usage
|
|
246
|
+
* useSearchShortcut(() => setSearchOpen(true))
|
|
247
|
+
*
|
|
248
|
+
* // With preload
|
|
249
|
+
* useSearchShortcut({
|
|
250
|
+
* onOpen: () => setSearchOpen(true),
|
|
251
|
+
* onPreload: () => search.preload()
|
|
252
|
+
* })
|
|
253
|
+
*/
|
|
254
|
+
export function useSearchShortcut(callbacks) {
|
|
255
|
+
const { onOpen, onPreload } = typeof callbacks === 'function'
|
|
256
|
+
? { onOpen: callbacks, onPreload: null }
|
|
257
|
+
: callbacks
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
const handleKeyDown = (e) => {
|
|
261
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
262
|
+
e.preventDefault()
|
|
263
|
+
onPreload?.()
|
|
264
|
+
onOpen()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
269
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
270
|
+
}, [onOpen, onPreload])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Hook that wraps useSearch with intent-based preloading
|
|
275
|
+
*
|
|
276
|
+
* Provides handlers to trigger preload on user intent (hover, focus, touch)
|
|
277
|
+
* rather than on component mount. This saves bandwidth for users who never search.
|
|
278
|
+
*
|
|
279
|
+
* @param {Object} website - Website instance from @uniweb/core
|
|
280
|
+
* @param {Object} options - Options passed to useSearch
|
|
281
|
+
* @returns {Object} Search state, methods, and intent handlers
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* function SearchButton({ onClick }) {
|
|
285
|
+
* const { intentProps, triggerPreload } = useSearchWithIntent(website)
|
|
286
|
+
*
|
|
287
|
+
* useSearchShortcut({
|
|
288
|
+
* onOpen: onClick,
|
|
289
|
+
* onPreload: triggerPreload,
|
|
290
|
+
* })
|
|
291
|
+
*
|
|
292
|
+
* return (
|
|
293
|
+
* <button onClick={onClick} {...intentProps}>
|
|
294
|
+
* Search
|
|
295
|
+
* </button>
|
|
296
|
+
* )
|
|
297
|
+
* }
|
|
298
|
+
*/
|
|
299
|
+
export function useSearchWithIntent(website, options = {}) {
|
|
300
|
+
const search = useSearch(website, options)
|
|
301
|
+
const hasPreloaded = useRef(false)
|
|
302
|
+
|
|
303
|
+
const triggerPreload = useCallback(() => {
|
|
304
|
+
if (hasPreloaded.current) return
|
|
305
|
+
hasPreloaded.current = true
|
|
306
|
+
search.preload()
|
|
307
|
+
}, [search])
|
|
308
|
+
|
|
309
|
+
// Intent handlers - spread onto interactive elements
|
|
310
|
+
const intentProps = useMemo(() => ({
|
|
311
|
+
onMouseEnter: triggerPreload,
|
|
312
|
+
onFocus: triggerPreload,
|
|
313
|
+
onTouchStart: triggerPreload,
|
|
314
|
+
}), [triggerPreload])
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
...search,
|
|
318
|
+
triggerPreload,
|
|
319
|
+
intentProps,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
239
323
|
export default useSearch
|
package/src/search/index.js
CHANGED
|
@@ -23,4 +23,4 @@
|
|
|
23
23
|
|
|
24
24
|
export { createSearchClient, loadSearchIndex, clearSearchCache } from './client.js'
|
|
25
25
|
export { buildSnippet, highlightMatches, escapeHtml } from './snippets.js'
|
|
26
|
-
export { useSearch, useSearchIndex } from './hooks.js'
|
|
26
|
+
export { useSearch, useSearchIndex, useSearchShortcut, useSearchWithIntent } from './hooks.js'
|
package/src/utils/index.js
CHANGED
|
@@ -9,6 +9,75 @@ import { twMerge, twJoin } from 'tailwind-merge'
|
|
|
9
9
|
// Re-export tailwind-merge utilities
|
|
10
10
|
export { twMerge, twJoin }
|
|
11
11
|
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────
|
|
13
|
+
// Locale Utilities
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Common locale display names (native language names)
|
|
18
|
+
* Used as fallback when site.yml doesn't specify labels.
|
|
19
|
+
* Tree-shakeable: only included if foundation uses getLocaleLabel().
|
|
20
|
+
*/
|
|
21
|
+
export const LOCALE_DISPLAY_NAMES = {
|
|
22
|
+
en: 'English',
|
|
23
|
+
es: 'Español',
|
|
24
|
+
fr: 'Français',
|
|
25
|
+
de: 'Deutsch',
|
|
26
|
+
it: 'Italiano',
|
|
27
|
+
pt: 'Português',
|
|
28
|
+
nl: 'Nederlands',
|
|
29
|
+
pl: 'Polski',
|
|
30
|
+
ru: 'Русский',
|
|
31
|
+
ja: '日本語',
|
|
32
|
+
ko: '한국어',
|
|
33
|
+
zh: '中文',
|
|
34
|
+
'zh-CN': '简体中文',
|
|
35
|
+
'zh-TW': '繁體中文',
|
|
36
|
+
ar: 'العربية',
|
|
37
|
+
he: 'עברית',
|
|
38
|
+
hi: 'हिन्दी',
|
|
39
|
+
th: 'ไทย',
|
|
40
|
+
vi: 'Tiếng Việt',
|
|
41
|
+
tr: 'Türkçe',
|
|
42
|
+
uk: 'Українська',
|
|
43
|
+
cs: 'Čeština',
|
|
44
|
+
el: 'Ελληνικά',
|
|
45
|
+
hu: 'Magyar',
|
|
46
|
+
ro: 'Română',
|
|
47
|
+
sv: 'Svenska',
|
|
48
|
+
da: 'Dansk',
|
|
49
|
+
fi: 'Suomi',
|
|
50
|
+
no: 'Norsk',
|
|
51
|
+
id: 'Bahasa Indonesia',
|
|
52
|
+
ms: 'Bahasa Melayu'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get display label for a locale
|
|
57
|
+
* Priority: locale.label (from site config) → LOCALE_DISPLAY_NAMES → code.toUpperCase()
|
|
58
|
+
*
|
|
59
|
+
* @param {Object|string} locale - Locale object {code, label?} or locale code string
|
|
60
|
+
* @returns {string} Display label for the locale
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* getLocaleLabel({ code: 'es', label: 'Spanish' }) // 'Spanish'
|
|
64
|
+
* getLocaleLabel({ code: 'es' }) // 'Español'
|
|
65
|
+
* getLocaleLabel('es') // 'Español'
|
|
66
|
+
* getLocaleLabel({ code: 'xx' }) // 'XX'
|
|
67
|
+
*/
|
|
68
|
+
export function getLocaleLabel(locale) {
|
|
69
|
+
// Handle string input (just a code)
|
|
70
|
+
if (typeof locale === 'string') {
|
|
71
|
+
return LOCALE_DISPLAY_NAMES[locale] || locale.toUpperCase()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle object input
|
|
75
|
+
if (!locale || !locale.code) return ''
|
|
76
|
+
|
|
77
|
+
// Priority: explicit label → known display name → uppercase code
|
|
78
|
+
return locale.label || LOCALE_DISPLAY_NAMES[locale.code] || locale.code.toUpperCase()
|
|
79
|
+
}
|
|
80
|
+
|
|
12
81
|
/**
|
|
13
82
|
* Merge class names with Tailwind CSS conflict resolution
|
|
14
83
|
* @param {...string} classes - Class names to merge
|