@uniweb/runtime 0.2.3 → 0.2.5

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.3",
3
+ "version": "0.2.5",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import React from 'react'
10
+ import { useLocation } from 'react-router-dom'
10
11
  import BlockRenderer from './BlockRenderer.jsx'
11
12
  import Layout from './Layout.jsx'
12
13
  import { useHeadMeta } from '../hooks/useHeadMeta.js'
@@ -35,11 +36,15 @@ export function ChildBlocks({ block, childBlocks, pure = false, extra = {} }) {
35
36
  * - Per-page layout preferences
36
37
  */
37
38
  export default function PageRenderer() {
39
+ const location = useLocation()
38
40
  const uniweb = globalThis.uniweb
39
41
  const website = uniweb?.activeWebsite
40
- const page = website?.activePage
41
42
  const siteName = website?.name || ''
42
43
 
44
+ // Get page from current URL path (not the potentially stale website.activePage)
45
+ // This ensures correct page renders immediately on client-side navigation
46
+ const page = website?.getPage(location.pathname) || website?.activePage
47
+
43
48
  // Get head metadata from page (uses Page.getHeadMeta() if available)
44
49
  const headMeta = page?.getHeadMeta?.() || {
45
50
  title: page?.title || 'Website',
@@ -8,6 +8,7 @@
8
8
  import React from 'react'
9
9
  import PageRenderer from './PageRenderer.jsx'
10
10
  import { useRememberScroll } from '../hooks/useRememberScroll.js'
11
+ import { useLinkInterceptor } from '../hooks/useLinkInterceptor.js'
11
12
 
12
13
  /**
13
14
  * Build CSS custom properties from theme data
@@ -59,6 +60,9 @@ function Fonts({ fontsData }) {
59
60
  export default function WebsiteRenderer() {
60
61
  const website = globalThis.uniweb?.activeWebsite
61
62
 
63
+ // Enable SPA navigation for links rendered as plain HTML
64
+ useLinkInterceptor({ enabled: true })
65
+
62
66
  // Enable scroll memory for navigation
63
67
  useRememberScroll({ enabled: true })
64
68
 
@@ -0,0 +1,137 @@
1
+ /**
2
+ * useLinkInterceptor Hook
3
+ *
4
+ * Intercepts clicks on internal links rendered as plain <a> tags
5
+ * (e.g., from markdown content) and uses React Router navigation
6
+ * instead of full page reloads.
7
+ *
8
+ * This enables SPA-style navigation for links that were rendered
9
+ * as raw HTML via dangerouslySetInnerHTML.
10
+ */
11
+
12
+ import { useEffect } from 'react'
13
+ import { useNavigate } from 'react-router-dom'
14
+
15
+ /**
16
+ * Check if a URL is internal (same origin, no external protocol)
17
+ * @param {string} href - The href to check
18
+ * @returns {boolean}
19
+ */
20
+ function isInternalLink(href) {
21
+ if (!href) return false
22
+
23
+ // Hash-only links are internal
24
+ if (href.startsWith('#')) return true
25
+
26
+ // Relative paths are internal
27
+ if (href.startsWith('/') && !href.startsWith('//')) return true
28
+
29
+ // Check if same origin
30
+ try {
31
+ const url = new URL(href, window.location.origin)
32
+ return url.origin === window.location.origin
33
+ } catch {
34
+ // Invalid URL, treat as internal relative path
35
+ return true
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Check if the click should trigger navigation
41
+ * @param {MouseEvent} event - The click event
42
+ * @returns {boolean}
43
+ */
44
+ function shouldNavigate(event) {
45
+ // Ignore if default was already prevented
46
+ if (event.defaultPrevented) return false
47
+
48
+ // Ignore modified clicks (new tab, etc.)
49
+ if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return false
50
+
51
+ // Ignore right-clicks
52
+ if (event.button !== 0) return false
53
+
54
+ return true
55
+ }
56
+
57
+ /**
58
+ * Find the closest anchor element from an event target
59
+ * @param {EventTarget} target - The event target
60
+ * @returns {HTMLAnchorElement|null}
61
+ */
62
+ function findAnchorElement(target) {
63
+ let element = target
64
+ while (element && element !== document.body) {
65
+ if (element.tagName === 'A') {
66
+ return element
67
+ }
68
+ element = element.parentElement
69
+ }
70
+ return null
71
+ }
72
+
73
+ /**
74
+ * useLinkInterceptor hook
75
+ *
76
+ * @param {Object} options
77
+ * @param {boolean} options.enabled - Enable link interception (default: true)
78
+ */
79
+ export function useLinkInterceptor(options = {}) {
80
+ const { enabled = true } = options
81
+ const navigate = useNavigate()
82
+
83
+ useEffect(() => {
84
+ if (!enabled) return
85
+
86
+ function handleClick(event) {
87
+ // Check if we should handle this click
88
+ if (!shouldNavigate(event)) return
89
+
90
+ // Find the anchor element
91
+ const anchor = findAnchorElement(event.target)
92
+ if (!anchor) return
93
+
94
+ // Get the href
95
+ const href = anchor.getAttribute('href')
96
+ if (!href) return
97
+
98
+ // Check if it's an internal link
99
+ if (!isInternalLink(href)) return
100
+
101
+ // Check for download attribute
102
+ if (anchor.hasAttribute('download')) return
103
+
104
+ // Check for target="_blank" or other non-self targets
105
+ const target = anchor.getAttribute('target')
106
+ if (target && target !== '_self') return
107
+
108
+ // Prevent the default browser navigation
109
+ event.preventDefault()
110
+
111
+ // Handle hash-only links
112
+ if (href.startsWith('#')) {
113
+ // Scroll to element or top
114
+ const elementId = href.slice(1)
115
+ if (elementId) {
116
+ const element = document.getElementById(elementId)
117
+ if (element) {
118
+ element.scrollIntoView({ behavior: 'smooth' })
119
+ }
120
+ }
121
+ return
122
+ }
123
+
124
+ // Use React Router navigation
125
+ navigate(href)
126
+ }
127
+
128
+ // Add click listener to document
129
+ document.addEventListener('click', handleClick)
130
+
131
+ return () => {
132
+ document.removeEventListener('click', handleClick)
133
+ }
134
+ }, [enabled, navigate])
135
+ }
136
+
137
+ export default useLinkInterceptor
@@ -36,11 +36,14 @@ export function useRememberScroll(options = {}) {
36
36
  const website = uniweb?.activeWebsite
37
37
  if (!website) return
38
38
 
39
- // Get current and previous pages
40
- const currentPage = website.activePage
41
39
  const previousPath = previousPathRef.current
42
40
  const currentPath = location.pathname
43
41
 
42
+ // Sync active page with current route
43
+ // This keeps website.activePage in sync for code that depends on it
44
+ website.setActivePage(currentPath)
45
+ const currentPage = website.activePage
46
+
44
47
  // Skip on first render
45
48
  if (isFirstRender.current) {
46
49
  isFirstRender.current = false