@uniweb/kit 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -38,7 +38,7 @@
38
38
  "fuse.js": "^7.0.0",
39
39
  "shiki": "^3.0.0",
40
40
  "tailwind-merge": "^2.6.0",
41
- "@uniweb/core": "0.6.1"
41
+ "@uniweb/core": "0.6.2"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "react": "^18.0.0 || ^19.0.0",
@@ -1,4 +1,6 @@
1
1
  export { useWebsite, default } from './useWebsite.js'
2
+ export { useFetched } from './useFetched.js'
3
+ export { useCacheEntry } from './useCacheEntry.js'
2
4
  export { useRouting } from './useRouting.js'
3
5
  export { useActiveRoute } from './useActiveRoute.js'
4
6
  export { useVersion } from './useVersion.js'
@@ -8,6 +10,8 @@ export { useAccordion } from './useAccordion.js'
8
10
  export { useGridLayout, getGridClasses } from './useGridLayout.js'
9
11
  export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
10
12
  export { useInView, useIsInView } from './useInView.js'
13
+ export { usePageState } from './usePageState.js'
14
+ export { useWebsiteState } from './useWebsiteState.js'
11
15
 
12
16
  // Theme data hooks (runtime theme access)
13
17
  export {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * useCacheEntry — read-only observation of a DataStore entry.
3
+ *
4
+ * Subscribes to a cache key without triggering a fetch. Returns null
5
+ * while the entry is absent; `{ data, meta }` once it's populated.
6
+ * Used for components that want to display data someone else fetched
7
+ * (e.g., an "articles count" shown in a header while the main page
8
+ * fetches the collection).
9
+ *
10
+ * Pairs with useFetched: useFetched triggers the dispatch and fills
11
+ * the cache; useCacheEntry reads it. They share the same keyspace.
12
+ *
13
+ * @example
14
+ * function ArticlesCount() {
15
+ * const entry = useCacheEntry({ path: '/data/articles.json', schema: 'articles' })
16
+ * if (!entry) return null
17
+ * return <span>{entry.data.length} articles</span>
18
+ * }
19
+ */
20
+
21
+ import { useEffect, useState } from 'react'
22
+ import { getUniweb, deriveCacheKey } from '@uniweb/core'
23
+
24
+ /**
25
+ * @param {Object|null} request - A fetch request spec. Pass null/undefined
26
+ * to skip (returns null without subscribing).
27
+ * @returns {{ data: any, meta?: Object } | null}
28
+ */
29
+ export function useCacheEntry(request) {
30
+ const website = getUniweb()?.activeWebsite ?? null
31
+ const key = request && website ? deriveCacheKey(request) : null
32
+
33
+ const [entry, setEntry] = useState(() =>
34
+ key ? website.dataStore.get(key) : null,
35
+ )
36
+
37
+ useEffect(() => {
38
+ if (!key || !website) {
39
+ setEntry(null)
40
+ return undefined
41
+ }
42
+
43
+ setEntry(website.dataStore.get(key))
44
+ const unsubscribe = website.dataStore.subscribe(key, () => {
45
+ setEntry(website.dataStore.get(key))
46
+ })
47
+ return unsubscribe
48
+ }, [key, website])
49
+
50
+ return entry
51
+ }
52
+
53
+ export default useCacheEntry
@@ -0,0 +1,115 @@
1
+ /**
2
+ * useFetched — imperative Layer-3 fetch hook.
3
+ *
4
+ * Shares the DataStore keyspace with Layer-1 (`content.data.*`) fetches.
5
+ * A declarative fetch for the same request warms the cache; this hook
6
+ * gets a synchronous cache hit on first render.
7
+ *
8
+ * Surface is intentionally narrow — no retries, no refetch intervals,
9
+ * no stale-while-revalidate. If you need those, reach for a
10
+ * purpose-built library and compose it around your own fetch.
11
+ *
12
+ * @example
13
+ * function ProductCard({ params }) {
14
+ * const { data, error, loading } = useFetched({
15
+ * url: params.productUrl,
16
+ * transform: 'data.product',
17
+ * })
18
+ * if (loading) return <Skeleton />
19
+ * if (error) return <Error message={error} />
20
+ * return <Product {...data} />
21
+ * }
22
+ */
23
+
24
+ import { useEffect, useState } from 'react'
25
+ import { getUniweb, deriveCacheKey } from '@uniweb/core'
26
+
27
+ function emptyState() {
28
+ return { data: null, error: null, loading: false }
29
+ }
30
+
31
+ function loadingState() {
32
+ return { data: null, error: null, loading: true }
33
+ }
34
+
35
+ function readyState(data) {
36
+ return { data, error: null, loading: false }
37
+ }
38
+
39
+ function errorState(error, data = null) {
40
+ return { data, error, loading: false }
41
+ }
42
+
43
+ /**
44
+ * @param {Object|null} request - A fetch request spec (path / url / schema / ...).
45
+ * Pass null/undefined to skip — the hook returns `{ data: null, error: null,
46
+ * loading: false }` without subscribing or dispatching.
47
+ * @returns {{ data: any, error: string|null, loading: boolean }}
48
+ */
49
+ export function useFetched(request) {
50
+ const website = getUniweb()?.activeWebsite ?? null
51
+
52
+ // Cache key is also the React dep. Serialized JSON so prop-identity-
53
+ // changes that don't affect the key don't re-run the effect.
54
+ const key = request && website ? deriveCacheKey(request) : null
55
+
56
+ // Synchronous hit on first render.
57
+ const initial = key ? website.dataStore.get(key) : null
58
+ const [state, setState] = useState(() =>
59
+ initial ? readyState(initial.data) : (key ? loadingState() : emptyState()),
60
+ )
61
+
62
+ useEffect(() => {
63
+ if (!key || !website) {
64
+ setState(emptyState())
65
+ return undefined
66
+ }
67
+
68
+ // Re-check cache after mount: the key may have changed between
69
+ // render and effect, or a parallel Layer-1 fetch may have landed.
70
+ const cached = website.dataStore.get(key)
71
+ if (cached) {
72
+ setState(readyState(cached.data))
73
+ } else {
74
+ setState(loadingState())
75
+ }
76
+
77
+ // Subscribe first so a concurrent Layer-1 fetch's cache write
78
+ // wakes this hook even if our own dispatch is still in flight.
79
+ const unsubscribe = website.dataStore.subscribe(key, () => {
80
+ const entry = website.dataStore.get(key)
81
+ if (entry) setState(readyState(entry.data))
82
+ })
83
+
84
+ const controller = new AbortController()
85
+ website.fetcher
86
+ .dispatch(request, { website, signal: controller.signal })
87
+ .then((result) => {
88
+ if (controller.signal.aborted) return
89
+ if (result?.error) {
90
+ setState(errorState(result.error, result.data ?? null))
91
+ return
92
+ }
93
+ // Successful results typically arrive via the subscription (the
94
+ // dispatcher writes to cache and fires listeners). Fall through
95
+ // here to handle cached-hit and null-data paths so a useFetched
96
+ // caller never sits in `loading: true` when dispatch returns.
97
+ if (result && 'data' in result) {
98
+ setState(readyState(result.data))
99
+ }
100
+ })
101
+ .catch((err) => {
102
+ if (controller.signal.aborted) return
103
+ setState(errorState(String(err?.message || err)))
104
+ })
105
+
106
+ return () => {
107
+ controller.abort()
108
+ unsubscribe()
109
+ }
110
+ }, [key, website])
111
+
112
+ return state
113
+ }
114
+
115
+ export default useFetched
@@ -0,0 +1,52 @@
1
+ /**
2
+ * usePageState — React bridge into the active page's ObservableState.
3
+ *
4
+ * Reads `page.state.get(key)` on render and subscribes to changes so
5
+ * React re-renders when the value flips. The setter writes back through
6
+ * `page.state.set(key, value)` — one source of truth, readable from
7
+ * outside React (the fetcher, sibling components, persistence helpers).
8
+ *
9
+ * Scope: the *active* page's state. Navigating to a different page
10
+ * swaps the observed scope; the new page's state is read instead.
11
+ * That's usually what foundations want (filters don't carry across
12
+ * pages). For site-wide state use `useWebsiteState`.
13
+ *
14
+ * @param {string} key - The key on page.state.
15
+ * @param {*} [defaultValue] - Returned when the key has never been set.
16
+ * @returns {[any, (value: any) => void]} [value, setValue]
17
+ *
18
+ * @example
19
+ * const [slug, setSlug] = usePageState('selectedQuery', 'all')
20
+ * return <select value={slug} onChange={e => setSlug(e.target.value)}>{...}</select>
21
+ */
22
+
23
+ import { useEffect, useReducer } from 'react'
24
+ import { useWebsite } from './useWebsite.js'
25
+
26
+ function forceTick(n) {
27
+ return n + 1
28
+ }
29
+
30
+ export function usePageState(key, defaultValue) {
31
+ const { website } = useWebsite()
32
+ const [, tick] = useReducer(forceTick, 0)
33
+
34
+ const page = website.activePage
35
+ const state = page?.state
36
+
37
+ useEffect(() => {
38
+ if (!state) return
39
+ return state.subscribe(key, tick)
40
+ }, [state, key])
41
+
42
+ const value = state?.has(key) ? state.get(key) : defaultValue
43
+
44
+ const setValue = (next) => {
45
+ if (!state) return
46
+ state.set(key, typeof next === 'function' ? next(value) : next)
47
+ }
48
+
49
+ return [value, setValue]
50
+ }
51
+
52
+ export default usePageState
@@ -0,0 +1,44 @@
1
+ /**
2
+ * useWebsiteState — React bridge into the Website's ObservableState.
3
+ *
4
+ * Sibling of `usePageState` but scoped site-wide rather than per-page.
5
+ * Typical uses: active appearance ('light'/'dark'/'system'), authenticated
6
+ * user, cross-page selections (a filter chosen on /search that other
7
+ * pages honor).
8
+ *
9
+ * Same contract: reads on render, subscribes to the keyed slot, re-renders
10
+ * when the value changes, writes via `website.state.set(key, value)`.
11
+ *
12
+ * @param {string} key
13
+ * @param {*} [defaultValue]
14
+ * @returns {[any, (value: any) => void]}
15
+ */
16
+
17
+ import { useEffect, useReducer } from 'react'
18
+ import { useWebsite } from './useWebsite.js'
19
+
20
+ function forceTick(n) {
21
+ return n + 1
22
+ }
23
+
24
+ export function useWebsiteState(key, defaultValue) {
25
+ const { website } = useWebsite()
26
+ const [, tick] = useReducer(forceTick, 0)
27
+ const state = website?.state
28
+
29
+ useEffect(() => {
30
+ if (!state) return
31
+ return state.subscribe(key, tick)
32
+ }, [state, key])
33
+
34
+ const value = state?.has(key) ? state.get(key) : defaultValue
35
+
36
+ const setValue = (next) => {
37
+ if (!state) return
38
+ state.set(key, typeof next === 'function' ? next(value) : next)
39
+ }
40
+
41
+ return [value, setValue]
42
+ }
43
+
44
+ export default useWebsiteState
package/src/index.js CHANGED
@@ -51,6 +51,9 @@ export {
51
51
 
52
52
  export {
53
53
  useWebsite,
54
+ // Layer-3 data hooks — share the DataStore keyspace with Layer 1.
55
+ useFetched,
56
+ useCacheEntry,
54
57
  useRouting,
55
58
  useActiveRoute,
56
59
  useVersion,
@@ -71,7 +74,10 @@ export {
71
74
  useColorContext,
72
75
  useAppearance,
73
76
  useThemeColor,
74
- useThemeColorVar
77
+ useThemeColorVar,
78
+ // Observable state bridges (page.state / website.state)
79
+ usePageState,
80
+ useWebsiteState
75
81
  } from './hooks/index.js'
76
82
 
77
83
  // ============================================================================
@@ -87,6 +93,8 @@ export {
87
93
  isFileUrl,
88
94
  detectMediaType,
89
95
  parseIconRef,
96
+ // Content utilities
97
+ splitContent,
90
98
  // Runtime utilities (getChildBlockRenderer is internal — use ChildBlocks)
91
99
  getChildBlockRenderer,
92
100
  ChildBlocks,
@@ -237,6 +237,12 @@ export function isFileUrl(url) {
237
237
  return fileExtensions.some(ext => lowerUrl.includes(ext))
238
238
  }
239
239
 
240
+ // ─────────────────────────────────────────────────────────────────
241
+ // Content Utilities
242
+ // ─────────────────────────────────────────────────────────────────
243
+
244
+ export { splitContent } from './splitContent.js'
245
+
240
246
  /**
241
247
  * Detect media type from URL
242
248
  * @param {string} url
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Split parsed content at divider elements in the sequence.
3
+ *
4
+ * Returns an array of content-like objects — one per region between
5
+ * dividers. Each object is a shallow copy of the original content
6
+ * with its own `sequence` slice. Grouped fields (title, paragraphs,
7
+ * items, etc.) are preserved from the original — only `sequence` is
8
+ * split. If no divider exists, returns a single-element array
9
+ * containing the original content.
10
+ *
11
+ * @param {Object} content - Parsed content from semantic parser
12
+ * @returns {Array<Object>} Array of content objects with split sequences
13
+ */
14
+ export function splitContent(content) {
15
+ const seq = content.sequence || []
16
+ const segments = [[]]
17
+ for (const el of seq) {
18
+ if (el.type === 'divider') segments.push([])
19
+ else segments[segments.length - 1].push(el)
20
+ }
21
+ if (segments.length === 1) return [content]
22
+ return segments.map(s => ({ ...content, sequence: s }))
23
+ }