@uniweb/runtime 0.1.2 → 0.2.2

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * useHeadMeta Hook
3
+ *
4
+ * Manages document head meta tags for SEO and social sharing.
5
+ * Updates meta tags when page changes in SPA navigation.
6
+ */
7
+
8
+ import { useEffect, useRef } from 'react'
9
+
10
+ /**
11
+ * Meta tag definitions for easy management
12
+ */
13
+ const META_TAGS = {
14
+ description: { name: 'description' },
15
+ keywords: { name: 'keywords' },
16
+ robots: { name: 'robots' },
17
+ 'og:title': { property: 'og:title' },
18
+ 'og:description': { property: 'og:description' },
19
+ 'og:image': { property: 'og:image' },
20
+ 'og:url': { property: 'og:url' },
21
+ 'og:type': { property: 'og:type' },
22
+ 'twitter:card': { name: 'twitter:card' },
23
+ 'twitter:title': { name: 'twitter:title' },
24
+ 'twitter:description': { name: 'twitter:description' },
25
+ 'twitter:image': { name: 'twitter:image' }
26
+ }
27
+
28
+ /**
29
+ * Get or create a meta tag element
30
+ * @param {string} key - Meta tag key (e.g., 'description', 'og:title')
31
+ * @returns {HTMLMetaElement}
32
+ */
33
+ function getOrCreateMetaTag(key) {
34
+ const config = META_TAGS[key]
35
+ if (!config) return null
36
+
37
+ const selector = config.property
38
+ ? `meta[property="${config.property}"]`
39
+ : `meta[name="${config.name}"]`
40
+
41
+ let element = document.querySelector(selector)
42
+
43
+ if (!element) {
44
+ element = document.createElement('meta')
45
+ if (config.property) {
46
+ element.setAttribute('property', config.property)
47
+ } else {
48
+ element.setAttribute('name', config.name)
49
+ }
50
+ document.head.appendChild(element)
51
+ }
52
+
53
+ return element
54
+ }
55
+
56
+ /**
57
+ * Get or create a link element (for canonical)
58
+ * @param {string} rel - Link rel attribute
59
+ * @returns {HTMLLinkElement}
60
+ */
61
+ function getOrCreateLinkTag(rel) {
62
+ let element = document.querySelector(`link[rel="${rel}"]`)
63
+
64
+ if (!element) {
65
+ element = document.createElement('link')
66
+ element.setAttribute('rel', rel)
67
+ document.head.appendChild(element)
68
+ }
69
+
70
+ return element
71
+ }
72
+
73
+ /**
74
+ * Set or remove a meta tag's content
75
+ * @param {string} key - Meta tag key
76
+ * @param {string|null} content - Content value (null to remove)
77
+ */
78
+ function setMetaContent(key, content) {
79
+ const element = getOrCreateMetaTag(key)
80
+ if (!element) return
81
+
82
+ if (content) {
83
+ element.setAttribute('content', content)
84
+ } else {
85
+ // Remove the tag if no content
86
+ element.remove()
87
+ }
88
+ }
89
+
90
+ /**
91
+ * useHeadMeta hook
92
+ *
93
+ * @param {Object} meta - Head metadata
94
+ * @param {string} meta.title - Page title
95
+ * @param {string} meta.description - Meta description
96
+ * @param {string} meta.keywords - Meta keywords (string or array)
97
+ * @param {string} meta.canonical - Canonical URL
98
+ * @param {string} meta.robots - Robots directive (e.g., 'noindex, nofollow')
99
+ * @param {Object} meta.og - Open Graph metadata
100
+ * @param {string} meta.og.title - OG title
101
+ * @param {string} meta.og.description - OG description
102
+ * @param {string} meta.og.image - OG image URL
103
+ * @param {string} meta.og.url - OG URL
104
+ * @param {Object} options - Hook options
105
+ * @param {string} options.siteName - Site name for title suffix
106
+ * @param {string} options.titleSeparator - Separator between page title and site name
107
+ */
108
+ export function useHeadMeta(meta, options = {}) {
109
+ const {
110
+ siteName = '',
111
+ titleSeparator = ' | '
112
+ } = options
113
+
114
+ // Track created elements for cleanup
115
+ const createdElements = useRef([])
116
+
117
+ useEffect(() => {
118
+ if (!meta) return
119
+
120
+ // Update document title
121
+ if (meta.title) {
122
+ const fullTitle = siteName
123
+ ? `${meta.title}${titleSeparator}${siteName}`
124
+ : meta.title
125
+ document.title = fullTitle
126
+ }
127
+
128
+ // Update meta description
129
+ setMetaContent('description', meta.description || null)
130
+
131
+ // Update meta keywords
132
+ const keywords = Array.isArray(meta.keywords)
133
+ ? meta.keywords.join(', ')
134
+ : meta.keywords
135
+ setMetaContent('keywords', keywords || null)
136
+
137
+ // Update robots
138
+ setMetaContent('robots', meta.robots || null)
139
+
140
+ // Update Open Graph tags
141
+ if (meta.og) {
142
+ setMetaContent('og:title', meta.og.title || meta.title || null)
143
+ setMetaContent('og:description', meta.og.description || meta.description || null)
144
+ setMetaContent('og:image', meta.og.image || null)
145
+ setMetaContent('og:url', meta.og.url || null)
146
+ setMetaContent('og:type', 'website')
147
+
148
+ // Twitter cards (fallback to OG values)
149
+ setMetaContent('twitter:card', meta.og.image ? 'summary_large_image' : 'summary')
150
+ setMetaContent('twitter:title', meta.og.title || meta.title || null)
151
+ setMetaContent('twitter:description', meta.og.description || meta.description || null)
152
+ setMetaContent('twitter:image', meta.og.image || null)
153
+ }
154
+
155
+ // Update canonical link
156
+ if (meta.canonical) {
157
+ const canonicalLink = getOrCreateLinkTag('canonical')
158
+ canonicalLink.setAttribute('href', meta.canonical)
159
+ } else {
160
+ // Remove canonical if not set
161
+ const existingCanonical = document.querySelector('link[rel="canonical"]')
162
+ if (existingCanonical) {
163
+ existingCanonical.remove()
164
+ }
165
+ }
166
+
167
+ // Cleanup function - reset to defaults on unmount
168
+ return () => {
169
+ // We don't remove tags on cleanup because another page will set them
170
+ // Just reset title as a fallback
171
+ document.title = siteName || 'Website'
172
+ }
173
+ }, [
174
+ meta?.title,
175
+ meta?.description,
176
+ meta?.keywords,
177
+ meta?.canonical,
178
+ meta?.robots,
179
+ meta?.og?.title,
180
+ meta?.og?.description,
181
+ meta?.og?.image,
182
+ meta?.og?.url,
183
+ siteName,
184
+ titleSeparator
185
+ ])
186
+ }
187
+
188
+ export default useHeadMeta
@@ -0,0 +1,105 @@
1
+ /**
2
+ * useRememberScroll Hook
3
+ *
4
+ * Remembers scroll position per page and restores it on navigation.
5
+ * Works with Page.scrollY property for persistence.
6
+ *
7
+ * Behavior:
8
+ * - Saves scroll position when navigating away from a page
9
+ * - Restores scroll position when returning to a previously visited page
10
+ * - Scrolls to top for newly visited pages
11
+ * - Optionally resets block states on scroll restoration
12
+ */
13
+
14
+ import { useEffect, useRef } from 'react'
15
+ import { useLocation } from 'react-router-dom'
16
+
17
+ /**
18
+ * useRememberScroll hook
19
+ *
20
+ * @param {Object} options
21
+ * @param {boolean} options.enabled - Enable scroll memory (default: true)
22
+ * @param {boolean} options.resetBlockStates - Reset block states on restore (default: true)
23
+ * @param {number} options.scrollDelay - Delay before restoring scroll (default: 0)
24
+ */
25
+ export function useRememberScroll(options = {}) {
26
+ const { enabled = true, resetBlockStates = true, scrollDelay = 0 } = options
27
+
28
+ const location = useLocation()
29
+ const previousPathRef = useRef(location.pathname)
30
+ const isFirstRender = useRef(true)
31
+
32
+ useEffect(() => {
33
+ if (!enabled) return
34
+
35
+ const uniweb = globalThis.uniweb
36
+ const website = uniweb?.activeWebsite
37
+ if (!website) return
38
+
39
+ // Get current and previous pages
40
+ const currentPage = website.activePage
41
+ const previousPath = previousPathRef.current
42
+ const currentPath = location.pathname
43
+
44
+ // Skip on first render
45
+ if (isFirstRender.current) {
46
+ isFirstRender.current = false
47
+ previousPathRef.current = currentPath
48
+ return
49
+ }
50
+
51
+ // Path hasn't changed (might be hash or search change)
52
+ if (previousPath === currentPath) {
53
+ return
54
+ }
55
+
56
+ // Save scroll position from previous page
57
+ const previousPage = website.getPage(previousPath)
58
+ if (previousPage) {
59
+ previousPage.scrollY = window.scrollY
60
+ }
61
+
62
+ // Restore or reset scroll for current page
63
+ if (currentPage) {
64
+ const targetScroll = currentPage.scrollY || 0
65
+
66
+ // Reset block states if requested (for animations, etc.)
67
+ if (resetBlockStates && typeof currentPage.resetBlockStates === 'function') {
68
+ currentPage.resetBlockStates()
69
+ }
70
+
71
+ // Restore scroll position
72
+ const restore = () => {
73
+ window.scrollTo(0, targetScroll)
74
+ }
75
+
76
+ if (scrollDelay > 0) {
77
+ setTimeout(restore, scrollDelay)
78
+ } else {
79
+ // Use requestAnimationFrame for smoother restoration
80
+ requestAnimationFrame(restore)
81
+ }
82
+ }
83
+
84
+ // Update previous path ref
85
+ previousPathRef.current = currentPath
86
+ }, [location.pathname, enabled, resetBlockStates, scrollDelay])
87
+
88
+ // Save scroll position before page unload
89
+ useEffect(() => {
90
+ if (!enabled) return
91
+
92
+ const handleBeforeUnload = () => {
93
+ const uniweb = globalThis.uniweb
94
+ const page = uniweb?.activeWebsite?.activePage
95
+ if (page) {
96
+ page.scrollY = window.scrollY
97
+ }
98
+ }
99
+
100
+ window.addEventListener('beforeunload', handleBeforeUnload)
101
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
102
+ }, [enabled])
103
+ }
104
+
105
+ export default useRememberScroll
@@ -0,0 +1,71 @@
1
+ /**
2
+ * useScrollDepth Hook
3
+ *
4
+ * Tracks scroll depth and reports to uniweb.analytics at milestones (25%, 50%, 75%, 100%).
5
+ * Only tracks if analytics is enabled.
6
+ */
7
+
8
+ import { useEffect, useRef } from 'react'
9
+
10
+ /**
11
+ * Calculate current scroll depth percentage
12
+ * @returns {number} Scroll depth 0-100
13
+ */
14
+ function getScrollDepth() {
15
+ const scrollTop = window.scrollY
16
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight
17
+
18
+ if (docHeight <= 0) return 100 // Page fits in viewport
19
+
20
+ return Math.min(100, Math.round((scrollTop / docHeight) * 100))
21
+ }
22
+
23
+ /**
24
+ * useScrollDepth hook
25
+ *
26
+ * Uses globalThis.uniweb.analytics for tracking.
27
+ *
28
+ * @param {Object} options
29
+ * @param {boolean} options.enabled - Enable tracking (default: true)
30
+ * @param {number} options.throttleMs - Throttle scroll events (default: 200)
31
+ */
32
+ export function useScrollDepth(options = {}) {
33
+ const { enabled = true, throttleMs = 200 } = options
34
+
35
+ const lastCheck = useRef(0)
36
+ const reportedMilestones = useRef(new Set())
37
+
38
+ useEffect(() => {
39
+ const analytics = globalThis.uniweb?.analytics
40
+
41
+ // Skip if analytics not available or disabled
42
+ if (!enabled || !analytics?.isEnabled?.()) return
43
+
44
+ // Reset milestones on mount (new page)
45
+ reportedMilestones.current.clear()
46
+
47
+ const handleScroll = () => {
48
+ const now = Date.now()
49
+ if (now - lastCheck.current < throttleMs) return
50
+ lastCheck.current = now
51
+
52
+ const depth = getScrollDepth()
53
+ const milestones = [25, 50, 75, 100]
54
+
55
+ for (const milestone of milestones) {
56
+ if (depth >= milestone && !reportedMilestones.current.has(milestone)) {
57
+ reportedMilestones.current.add(milestone)
58
+ analytics.trackScrollDepth(milestone)
59
+ }
60
+ }
61
+ }
62
+
63
+ // Check initial scroll position
64
+ handleScroll()
65
+
66
+ window.addEventListener('scroll', handleScroll, { passive: true })
67
+ return () => window.removeEventListener('scroll', handleScroll)
68
+ }, [enabled, throttleMs])
69
+ }
70
+
71
+ export default useScrollDepth
package/src/index.jsx CHANGED
@@ -1,23 +1,23 @@
1
1
  /**
2
2
  * @uniweb/runtime - Main Entry Point
3
3
  *
4
- * This is the Vite/ESM-based runtime that loads foundations via dynamic import()
5
- * instead of Webpack Module Federation.
4
+ * Minimal runtime for loading foundations and orchestrating rendering.
5
+ * Foundations should import components from @uniweb/kit.
6
6
  */
7
7
 
8
8
  import React from 'react'
9
9
  import { createRoot } from 'react-dom/client'
10
- import { BrowserRouter, Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom'
10
+ import { BrowserRouter, Routes, Route } from 'react-router-dom'
11
11
 
12
12
  // Components
13
13
  import { ChildBlocks } from './components/PageRenderer.jsx'
14
- import Link from './components/Link.jsx'
15
- import SafeHtml from './components/SafeHtml.jsx'
16
14
  import WebsiteRenderer from './components/WebsiteRenderer.jsx'
17
15
  import ErrorBoundary from './components/ErrorBoundary.jsx'
16
+ import Layout from './components/Layout.jsx'
17
+ import Blocks from './components/Blocks.jsx'
18
18
 
19
- // Core
20
- import Uniweb from './core/uniweb.js'
19
+ // Core factory from @uniweb/core
20
+ import { createUniweb } from '@uniweb/core'
21
21
 
22
22
  /**
23
23
  * Load foundation CSS from URL
@@ -60,8 +60,10 @@ async function loadFoundation(source) {
60
60
  import(/* @vite-ignore */ url)
61
61
  ])
62
62
 
63
- console.log('[Runtime] Foundation loaded. Available components:',
64
- typeof foundation.listComponents === 'function' ? foundation.listComponents() : 'unknown')
63
+ console.log(
64
+ '[Runtime] Foundation loaded. Available components:',
65
+ typeof foundation.listComponents === 'function' ? foundation.listComponents() : 'unknown'
66
+ )
65
67
 
66
68
  return foundation
67
69
  } catch (error) {
@@ -76,23 +78,12 @@ async function loadFoundation(source) {
76
78
  * @returns {Uniweb}
77
79
  */
78
80
  function initUniweb(configData) {
79
- const uniwebInstance = new Uniweb(configData)
81
+ // Create singleton via @uniweb/core (also assigns to globalThis.uniweb)
82
+ const uniwebInstance = createUniweb(configData)
80
83
 
81
- // Global assignment for component access
82
- globalThis.uniweb = uniwebInstance
83
-
84
- // Set up child block renderer
84
+ // Set up child block renderer for nested blocks
85
85
  uniwebInstance.childBlockRenderer = ChildBlocks
86
86
 
87
- // Set up routing components for foundation components to use
88
- uniwebInstance.routingComponents = {
89
- Link,
90
- SafeHtml,
91
- useNavigate,
92
- useParams,
93
- useLocation
94
- }
95
-
96
87
  return uniwebInstance
97
88
  }
98
89
 
@@ -149,9 +140,10 @@ async function initRuntime(foundationSource, options = {}) {
149
140
  } = options
150
141
 
151
142
  // Get config data from options, DOM, or global
152
- const configData = providedConfig
153
- ?? JSON.parse(document.getElementById('__SITE_CONTENT__')?.textContent || 'null')
154
- ?? globalThis.__SITE_CONTENT__
143
+ const configData =
144
+ providedConfig ??
145
+ JSON.parse(document.getElementById('__SITE_CONTENT__')?.textContent || 'null') ??
146
+ globalThis.__SITE_CONTENT__
155
147
 
156
148
  if (!configData) {
157
149
  console.error('[Runtime] No site configuration found')
@@ -205,7 +197,7 @@ async function initRuntime(foundationSource, options = {}) {
205
197
  '%c<%c>%c Uniweb Runtime',
206
198
  'color: #FA8400; font-weight: bold; font-size: 18px;',
207
199
  'color: #00ADFE; font-weight: bold; font-size: 18px;',
208
- "color: #333; font-size: 18px; font-family: system-ui, sans-serif;"
200
+ 'color: #333; font-size: 18px; font-family: system-ui, sans-serif;'
209
201
  )
210
202
  }
211
203
  } catch (error) {
@@ -216,7 +208,15 @@ async function initRuntime(foundationSource, options = {}) {
216
208
  if (container) {
217
209
  const root = createRoot(container)
218
210
  root.render(
219
- <div style={{ padding: '2rem', margin: '1rem', background: '#fef2f2', borderRadius: '0.5rem', color: '#dc2626' }}>
211
+ <div
212
+ style={{
213
+ padding: '2rem',
214
+ margin: '1rem',
215
+ background: '#fef2f2',
216
+ borderRadius: '0.5rem',
217
+ color: '#dc2626'
218
+ }}
219
+ >
220
220
  <h2>Runtime Error</h2>
221
221
  <p>{error.message}</p>
222
222
  {development && <pre style={{ fontSize: '0.75rem', overflow: 'auto' }}>{error.stack}</pre>}
@@ -226,20 +226,5 @@ async function initRuntime(foundationSource, options = {}) {
226
226
  }
227
227
  }
228
228
 
229
- // Legacy alias
230
- const initRTE = initRuntime
231
-
232
- // Exports
233
- export {
234
- initRuntime,
235
- initRTE,
236
- // Components for external use
237
- Link,
238
- SafeHtml,
239
- ChildBlocks,
240
- ErrorBoundary,
241
- // Core classes
242
- Uniweb
243
- }
244
-
229
+ export { initRuntime }
245
230
  export default initRuntime
@@ -1,28 +0,0 @@
1
- /**
2
- * Link
3
- *
4
- * A wrapper around React Router's Link that integrates with the runtime.
5
- */
6
-
7
- import React from 'react'
8
- import { Link as RouterLink } from 'react-router-dom'
9
-
10
- export default function Link({ to, href, children, className, ...props }) {
11
- const target = to || href
12
-
13
- // External links
14
- if (target?.startsWith('http') || target?.startsWith('mailto:') || target?.startsWith('tel:')) {
15
- return (
16
- <a href={target} className={className} {...props}>
17
- {children}
18
- </a>
19
- )
20
- }
21
-
22
- // Internal links via React Router
23
- return (
24
- <RouterLink to={target || '/'} className={className} {...props}>
25
- {children}
26
- </RouterLink>
27
- )
28
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * SafeHtml
3
- *
4
- * Safely render HTML content with sanitization.
5
- * TODO: Add DOMPurify for production use
6
- */
7
-
8
- import React from 'react'
9
-
10
- export default function SafeHtml({ html, className, as: Component = 'div', ...props }) {
11
- if (!html) return null
12
-
13
- // For now, render directly
14
- // TODO: Integrate DOMPurify for sanitization
15
- return (
16
- <Component
17
- className={className}
18
- dangerouslySetInnerHTML={{ __html: html }}
19
- {...props}
20
- />
21
- )
22
- }