@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.
- package/README.md +48 -171
- package/package.json +8 -20
- package/src/components/Blocks.jsx +26 -0
- package/src/components/Layout.jsx +100 -0
- package/src/components/PageRenderer.jsx +26 -21
- package/src/components/WebsiteRenderer.jsx +5 -0
- package/src/hooks/useHeadMeta.js +188 -0
- package/src/hooks/useRememberScroll.js +105 -0
- package/src/hooks/useScrollDepth.js +71 -0
- package/src/index.jsx +29 -44
- package/src/components/Link.jsx +0 -28
- package/src/components/SafeHtml.jsx +0 -22
- package/src/core/block.js +0 -311
- package/src/core/input.js +0 -15
- package/src/core/page.js +0 -75
- package/src/core/uniweb.js +0 -103
- package/src/core/website.js +0 -157
- package/src/vite/content-collector.js +0 -269
- package/src/vite/foundation-plugin.js +0 -194
- package/src/vite/index.js +0 -7
- package/src/vite/site-content-plugin.js +0 -135
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
64
|
-
|
|
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
|
-
|
|
81
|
+
// Create singleton via @uniweb/core (also assigns to globalThis.uniweb)
|
|
82
|
+
const uniwebInstance = createUniweb(configData)
|
|
80
83
|
|
|
81
|
-
//
|
|
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 =
|
|
153
|
-
??
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/components/Link.jsx
DELETED
|
@@ -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
|
-
}
|