@uniweb/runtime 0.2.4 → 0.2.6

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.4",
3
+ "version": "0.2.6",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
package/src/index.jsx CHANGED
@@ -226,5 +226,33 @@ async function initRuntime(foundationSource, options = {}) {
226
226
  }
227
227
  }
228
228
 
229
- export { initRuntime }
229
+ /**
230
+ * Simplified entry point for sites
231
+ *
232
+ * Reads foundation configuration from __FOUNDATION_CONFIG__ which is injected
233
+ * by the Vite config based on site.yml settings.
234
+ *
235
+ * @param {Object} options
236
+ * @param {Promise} options.foundation - Promise from import('#foundation')
237
+ * @param {Promise} options.styles - Promise from import('#foundation/styles')
238
+ */
239
+ async function start({ foundation, styles } = {}) {
240
+ // Read config injected by Vite's define option (from site.yml)
241
+ const config =
242
+ typeof __FOUNDATION_CONFIG__ !== 'undefined' ? __FOUNDATION_CONFIG__ : { mode: 'bundled' }
243
+
244
+ if (config.mode === 'runtime') {
245
+ // Runtime mode: load foundation dynamically from URL
246
+ return initRuntime({
247
+ url: config.url,
248
+ cssUrl: config.cssUrl
249
+ })
250
+ } else {
251
+ // Bundled mode: await the foundation and styles imports
252
+ const [foundationModule] = await Promise.all([foundation, styles])
253
+ return initRuntime(foundationModule)
254
+ }
255
+ }
256
+
257
+ export { initRuntime, start }
230
258
  export default initRuntime