@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 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, Section, useWebsite } from '@uniweb/kit'
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
- <Section width="xl" padding="lg">
21
- <Image src="/hero.jpg" alt="Hero" className="rounded-xl" />
22
- <h1>{localize({ en: 'Welcome', fr: 'Bienvenue' })}</h1>
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
- </Section>
45
+ </div>
25
46
  )
26
47
  }
27
48
  ```
28
49
 
29
- ## Why @uniweb/kit?
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
- ### When to use kit vs custom components
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
- | Use @uniweb/kit for | Use custom components for |
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
- ### Primitives
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 not provided) |
78
+ | `title` | `string` | Tooltip (auto-generated if omitted) |
74
79
  | `target` | `string` | Link target |
75
80
  | `download` | `boolean` | Force download behavior |
76
81
 
77
- #### Image
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` | Size preset: xs, sm, md, lg, xl, 2xl, full |
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
- #### SafeHtml
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
- #### Icon
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 icons: check, alert, user, heart, settings, star, close, menu, chevronDown, chevronRight, externalLink, download, play
127
+ Built-in: check, alert, user, heart, settings, star, close, menu, chevronDown, chevronRight, externalLink, download, play
123
128
 
124
- ### Typography
129
+ ### SocialIcon
125
130
 
126
- #### Text
131
+ Social media platform icons with automatic detection.
127
132
 
128
- Smart typography component for rendering semantic parser output. Handles strings or arrays with automatic tag selection.
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
- // Using Text directly
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
- **Semantic aliases**: `H1`, `H2`, `H3`, `H4`, `H5`, `H6`, `P`, `Span`, `Div`, `PlainText`
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
- | Prop | Type | Description |
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
- #### MediaIcon
193
+ ### MediaIcon
198
194
 
199
- Social media platform icons.
195
+ Platform icons (YouTube, Vimeo, etc.).
200
196
 
201
197
  ```jsx
202
198
  import { MediaIcon } from '@uniweb/kit'
203
199
 
204
- <MediaIcon type="twitter" size="24" />
205
- <MediaIcon type="linkedin" className="text-blue-600" />
200
+ <MediaIcon type="youtube" size="24" />
206
201
  ```
207
202
 
208
- Supported: facebook, twitter, x, linkedin, instagram, youtube, github, medium, pinterest, email, phone, orcid, researchgate
203
+ ### Asset
209
204
 
210
- ### Content
205
+ File preview with download functionality.
211
206
 
212
- #### Section
207
+ ```jsx
208
+ import { Asset } from '@uniweb/kit'
209
+
210
+ <Asset value="document.pdf" profile={profile} />
211
+ ```
212
+
213
+ ---
213
214
 
214
- Rich content section with layout options.
215
+ ## Hooks
216
+
217
+ ### useWebsite
218
+
219
+ Access website instance and utilities.
215
220
 
216
221
  ```jsx
217
- import { Section } from '@uniweb/kit'
222
+ import { useWebsite } from '@uniweb/kit'
218
223
 
219
- <Section content={blockContent} width="lg" padding="md" />
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
- <Section width="xl" columns="2" className="bg-gray-50">
222
- <div>Column 1</div>
223
- <div>Column 2</div>
224
- </Section>
233
+ return <div>{localize({ en: 'Hello', fr: 'Bonjour' })}</div>
234
+ }
225
235
  ```
226
236
 
227
- | Prop | Type | Description |
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
- #### Render
239
+ Detect active navigation state.
236
240
 
237
- Content block renderer (used internally by Section).
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 { Render } from '@uniweb/kit'
263
+ import { useScrolled } from '@uniweb/kit'
241
264
 
242
- <Render content={proseMirrorContent} />
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
- #### Content Renderers
276
+ ### useMobileMenu
246
277
 
247
- Individual content type renderers:
278
+ Mobile menu state management.
248
279
 
249
280
  ```jsx
250
- import { Code, Alert, Table, Details, Divider } from '@uniweb/kit'
281
+ import { useMobileMenu } from '@uniweb/kit'
282
+
283
+ function Navbar() {
284
+ const { isOpen, toggle, close } = useMobileMenu()
251
285
 
252
- <Code content="const x = 1" language="javascript" />
253
- <Alert type="warning" content="Be careful!" />
254
- <Table content={tableData} />
255
- <Details summary="Click to expand" content="Hidden content" />
256
- <Divider type="dots" />
286
+ return (
287
+ <>
288
+ <button onClick={toggle}>Menu</button>
289
+ {isOpen && <MobileMenu onClose={close} />}
290
+ </>
291
+ )
292
+ }
257
293
  ```
258
294
 
259
- ### Utilities
295
+ ### useAccordion
260
296
 
261
- #### Asset
297
+ Accordion/FAQ state management.
262
298
 
263
- File preview with download functionality.
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 { Asset } from '@uniweb/kit'
319
+ import { useInView, useIsInView } from '@uniweb/kit'
267
320
 
268
- <Asset value="document.pdf" profile={profile} />
269
- <Asset value={{ src: "/files/report.pdf", filename: "report.pdf" }} />
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
- #### Disclaimer
338
+ ### useGridLayout
273
339
 
274
- Modal disclaimer dialog.
340
+ Responsive grid utilities.
275
341
 
276
342
  ```jsx
277
- import { Disclaimer } from '@uniweb/kit'
343
+ import { useGridLayout, getGridClasses } from '@uniweb/kit'
278
344
 
279
- <Disclaimer
280
- title="Terms of Service"
281
- content="<p>Please read our terms...</p>"
282
- triggerText="View Terms"
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
- ## Hooks
356
+ ### Theme Hooks
287
357
 
288
- ### useWebsite
358
+ Access site theming data at runtime.
289
359
 
290
- Access website instance and utilities.
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 { useWebsite } from '@uniweb/kit'
411
+ import { useSearch } from '@uniweb/kit/search'
294
412
 
295
- function MyComponent() {
296
- const {
297
- website, // Website instance
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
- return <div>{localize({ en: 'Hello', fr: 'Bonjour' })}</div>
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
- ## Utility Functions
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>') // "Hello"
533
+ stripTags('<p>Hello</p>') // "Hello"
318
534
 
319
535
  // URL utilities
320
- isExternalUrl('https://google.com') // true
321
- isFileUrl('/files/doc.pdf') // true
322
- detectMediaType('https://youtube.com/...') // 'youtube'
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 into foundation)
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
- ## For Foundation Creators
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
- ```js
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
- This approach lets you:
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.11",
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.16"
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 topic: protocol (internal reference)
174
- if (linkHref.startsWith('topic:')) {
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
 
@@ -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'
@@ -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
@@ -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'
@@ -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