@uniweb/runtime 0.2.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,7 +29,7 @@
29
29
  "node": ">=20.19"
30
30
  },
31
31
  "dependencies": {
32
- "@uniweb/core": "0.1.2"
32
+ "@uniweb/core": "0.1.5"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.0.0 || ^19.0.0",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Blocks
3
+ *
4
+ * Renders an array of blocks for a layout area (header, body, footer, panels).
5
+ * Used by the Layout component to pre-render each area.
6
+ */
7
+
8
+ import React from 'react'
9
+ import BlockRenderer from './BlockRenderer.jsx'
10
+
11
+ /**
12
+ * Render a list of blocks
13
+ *
14
+ * @param {Object} props
15
+ * @param {Block[]} props.blocks - Array of Block instances to render
16
+ * @param {Object} [props.extra] - Extra props to pass to each block
17
+ */
18
+ export default function Blocks({ blocks, extra = {} }) {
19
+ if (!blocks || blocks.length === 0) return null
20
+
21
+ return blocks.map((block, index) => (
22
+ <React.Fragment key={block.id || index}>
23
+ <BlockRenderer block={block} extra={extra} />
24
+ </React.Fragment>
25
+ ))
26
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Layout
3
+ *
4
+ * Orchestrates page rendering by assembling layout areas (header, body, footer, panels).
5
+ * Supports foundation-provided custom Layout components via website.getRemoteLayout().
6
+ *
7
+ * Layout Areas:
8
+ * - header: Top navigation, branding (from @header page)
9
+ * - body: Main page content (from page sections)
10
+ * - footer: Bottom navigation, copyright (from @footer page)
11
+ * - left: Left sidebar/panel (from @left page)
12
+ * - right: Right sidebar/panel (from @right page)
13
+ *
14
+ * Custom Layouts:
15
+ * Foundations can export a Layout component that receives pre-rendered areas as props:
16
+ *
17
+ * ```jsx
18
+ * export const site = {
19
+ * Layout: ({ page, website, header, body, footer, left, right }) => (
20
+ * <div className="my-layout">
21
+ * <header>{header}</header>
22
+ * <aside>{left}</aside>
23
+ * <main>{body}</main>
24
+ * <aside>{right}</aside>
25
+ * <footer>{footer}</footer>
26
+ * </div>
27
+ * )
28
+ * }
29
+ * ```
30
+ */
31
+
32
+ import React from 'react'
33
+ import Blocks from './Blocks.jsx'
34
+
35
+ /**
36
+ * Default layout - renders header, body, footer in sequence
37
+ * (no panels in default layout)
38
+ */
39
+ function DefaultLayout({ header, body, footer }) {
40
+ return (
41
+ <>
42
+ {header}
43
+ {body}
44
+ {footer}
45
+ </>
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Layout component
51
+ *
52
+ * @param {Object} props
53
+ * @param {Page} props.page - Current page instance
54
+ * @param {Website} props.website - Website instance
55
+ */
56
+ export default function Layout({ page, website }) {
57
+ // Check if foundation provides a custom Layout
58
+ const RemoteLayout = website.getRemoteLayout()
59
+
60
+ // Get block groups from page (respects layout preferences)
61
+ const headerBlocks = page.getHeaderBlocks()
62
+ const bodyBlocks = page.getBodyBlocks()
63
+ const footerBlocks = page.getFooterBlocks()
64
+ const leftBlocks = page.getLeftBlocks()
65
+ const rightBlocks = page.getRightBlocks()
66
+
67
+ // Pre-render each area as React elements
68
+ const headerElement = headerBlocks ? <Blocks blocks={headerBlocks} /> : null
69
+ const bodyElement = bodyBlocks ? <Blocks blocks={bodyBlocks} /> : null
70
+ const footerElement = footerBlocks ? <Blocks blocks={footerBlocks} /> : null
71
+ const leftElement = leftBlocks ? <Blocks blocks={leftBlocks} /> : null
72
+ const rightElement = rightBlocks ? <Blocks blocks={rightBlocks} /> : null
73
+
74
+ // Use foundation's custom Layout if provided
75
+ if (RemoteLayout) {
76
+ return (
77
+ <RemoteLayout
78
+ page={page}
79
+ website={website}
80
+ header={headerElement}
81
+ body={bodyElement}
82
+ footer={footerElement}
83
+ left={leftElement}
84
+ right={rightElement}
85
+ // Aliases for backwards compatibility
86
+ leftPanel={leftElement}
87
+ rightPanel={rightElement}
88
+ />
89
+ )
90
+ }
91
+
92
+ // Default layout
93
+ return (
94
+ <DefaultLayout
95
+ header={headerElement}
96
+ body={bodyElement}
97
+ footer={footerElement}
98
+ />
99
+ )
100
+ }
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * PageRenderer
3
3
  *
4
- * Renders a page by iterating through its blocks.
4
+ * Renders a page using the Layout component for proper orchestration
5
+ * of header, body, footer, and panel areas.
6
+ * Manages head meta tags for SEO and social sharing.
5
7
  */
6
8
 
7
- import React, { useEffect } from 'react'
9
+ import React from 'react'
8
10
  import BlockRenderer from './BlockRenderer.jsx'
11
+ import Layout from './Layout.jsx'
12
+ import { useHeadMeta } from '../hooks/useHeadMeta.js'
9
13
 
10
14
  /**
11
15
  * ChildBlocks - renders child blocks of a block
@@ -23,17 +27,27 @@ export function ChildBlocks({ block, childBlocks, pure = false, extra = {} }) {
23
27
 
24
28
  /**
25
29
  * PageRenderer component
30
+ *
31
+ * Renders the current page using the Layout system which supports:
32
+ * - Header, body, footer areas
33
+ * - Left and right panels
34
+ * - Foundation-provided custom layouts
35
+ * - Per-page layout preferences
26
36
  */
27
37
  export default function PageRenderer() {
28
- const page = globalThis.uniweb?.activeWebsite?.activePage
29
- const pageTitle = page?.title || 'Website'
38
+ const uniweb = globalThis.uniweb
39
+ const website = uniweb?.activeWebsite
40
+ const page = website?.activePage
41
+ const siteName = website?.name || ''
42
+
43
+ // Get head metadata from page (uses Page.getHeadMeta() if available)
44
+ const headMeta = page?.getHeadMeta?.() || {
45
+ title: page?.title || 'Website',
46
+ description: page?.description || ''
47
+ }
30
48
 
31
- useEffect(() => {
32
- document.title = pageTitle
33
- return () => {
34
- document.title = 'Website'
35
- }
36
- }, [pageTitle])
49
+ // Manage head meta tags
50
+ useHeadMeta(headMeta, { siteName })
37
51
 
38
52
  if (!page) {
39
53
  return (
@@ -43,15 +57,6 @@ export default function PageRenderer() {
43
57
  )
44
58
  }
45
59
 
46
- const blocks = page.getPageBlocks()
47
-
48
- return (
49
- <>
50
- {blocks.map((block, index) => (
51
- <React.Fragment key={block.id || index}>
52
- <BlockRenderer block={block} />
53
- </React.Fragment>
54
- ))}
55
- </>
56
- )
60
+ // Use Layout component for proper orchestration
61
+ return <Layout page={page} website={website} />
57
62
  }
@@ -2,10 +2,12 @@
2
2
  * WebsiteRenderer
3
3
  *
4
4
  * Top-level renderer that sets up theme styles and renders pages.
5
+ * Manages scroll memory for navigation and optional analytics.
5
6
  */
6
7
 
7
8
  import React from 'react'
8
9
  import PageRenderer from './PageRenderer.jsx'
10
+ import { useRememberScroll } from '../hooks/useRememberScroll.js'
9
11
 
10
12
  /**
11
13
  * Build CSS custom properties from theme data
@@ -57,6 +59,9 @@ function Fonts({ fontsData }) {
57
59
  export default function WebsiteRenderer() {
58
60
  const website = globalThis.uniweb?.activeWebsite
59
61
 
62
+ // Enable scroll memory for navigation
63
+ useRememberScroll({ enabled: true })
64
+
60
65
  if (!website) {
61
66
  return (
62
67
  <div className="website-loading" style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>
@@ -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
@@ -13,6 +13,8 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'
13
13
  import { ChildBlocks } from './components/PageRenderer.jsx'
14
14
  import WebsiteRenderer from './components/WebsiteRenderer.jsx'
15
15
  import ErrorBoundary from './components/ErrorBoundary.jsx'
16
+ import Layout from './components/Layout.jsx'
17
+ import Blocks from './components/Blocks.jsx'
16
18
 
17
19
  // Core factory from @uniweb/core
18
20
  import { createUniweb } from '@uniweb/core'