@uniweb/runtime 0.6.9 → 0.6.10
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 +6 -3
- package/src/RuntimeProvider.jsx +42 -0
- package/src/foundation-loader.js +95 -0
- package/src/index.jsx +30 -371
- package/src/setup.js +257 -0
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.jsx",
|
|
8
|
-
"./ssr": "./dist/ssr.js"
|
|
8
|
+
"./ssr": "./dist/ssr.js",
|
|
9
|
+
"./provider": "./src/RuntimeProvider.jsx",
|
|
10
|
+
"./setup": "./src/setup.js",
|
|
11
|
+
"./foundation-loader": "./src/foundation-loader.js"
|
|
9
12
|
},
|
|
10
13
|
"files": [
|
|
11
14
|
"src",
|
|
@@ -31,7 +34,7 @@
|
|
|
31
34
|
"node": ">=20.19"
|
|
32
35
|
},
|
|
33
36
|
"dependencies": {
|
|
34
|
-
"@uniweb/core": "0.5.
|
|
37
|
+
"@uniweb/core": "0.5.10"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
40
|
"@vitejs/plugin-react": "^4.5.2",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuntimeProvider
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the full React rendering tree for a Uniweb site:
|
|
5
|
+
* ErrorBoundary → BrowserRouter → Routes → WebsiteRenderer.
|
|
6
|
+
*
|
|
7
|
+
* The Uniweb singleton (globalThis.uniweb) must be set up BEFORE rendering
|
|
8
|
+
* this component. RuntimeProvider reads from the singleton — it does not
|
|
9
|
+
* manage initialization. The singleton is imperative infrastructure that
|
|
10
|
+
* sits underneath React; React is a rendering layer within it.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} props
|
|
13
|
+
* @param {string} [props.basename] - Router basename for subdirectory deployments
|
|
14
|
+
* @param {boolean} [props.development] - Enable React StrictMode
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React from 'react'
|
|
18
|
+
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
|
19
|
+
import WebsiteRenderer from './components/WebsiteRenderer.jsx'
|
|
20
|
+
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
|
21
|
+
|
|
22
|
+
export default function RuntimeProvider({ basename, development = false }) {
|
|
23
|
+
const website = globalThis.uniweb?.activeWebsite
|
|
24
|
+
if (!website) return null
|
|
25
|
+
|
|
26
|
+
// Set basePath for subdirectory deployments
|
|
27
|
+
if (website.setBasePath) {
|
|
28
|
+
website.setBasePath(basename || '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const app = (
|
|
32
|
+
<ErrorBoundary>
|
|
33
|
+
<BrowserRouter basename={basename}>
|
|
34
|
+
<Routes>
|
|
35
|
+
<Route path="/*" element={<WebsiteRenderer />} />
|
|
36
|
+
</Routes>
|
|
37
|
+
</BrowserRouter>
|
|
38
|
+
</ErrorBoundary>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return development ? <React.StrictMode>{app}</React.StrictMode> : app
|
|
42
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundation and extension loading
|
|
3
|
+
*
|
|
4
|
+
* Handles dynamic import of foundations (primary and extensions)
|
|
5
|
+
* with CSS loading in parallel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load foundation CSS from URL
|
|
10
|
+
* @param {string} url - URL to foundation's CSS file
|
|
11
|
+
*/
|
|
12
|
+
async function loadFoundationCSS(url) {
|
|
13
|
+
if (!url) return
|
|
14
|
+
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const link = document.createElement('link')
|
|
17
|
+
link.rel = 'stylesheet'
|
|
18
|
+
link.href = url
|
|
19
|
+
link.onload = () => {
|
|
20
|
+
console.log('[Runtime] Foundation CSS loaded')
|
|
21
|
+
resolve()
|
|
22
|
+
}
|
|
23
|
+
link.onerror = () => {
|
|
24
|
+
console.warn('[Runtime] Could not load foundation CSS from:', url)
|
|
25
|
+
resolve() // Don't fail for CSS
|
|
26
|
+
}
|
|
27
|
+
document.head.appendChild(link)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load a foundation module via dynamic import
|
|
33
|
+
* @param {string|Object} source - URL string or {url, cssUrl} object
|
|
34
|
+
* @returns {Promise<Object>} The loaded foundation module
|
|
35
|
+
*/
|
|
36
|
+
export async function loadFoundation(source) {
|
|
37
|
+
const url = typeof source === 'string' ? source : source.url
|
|
38
|
+
// Auto-derive CSS URL from JS URL by convention: foundation.js → assets/foundation.css
|
|
39
|
+
const cssUrl = typeof source === 'object' ? source.cssUrl
|
|
40
|
+
: url.replace(/[^/]+\.js$/, 'assets/foundation.css')
|
|
41
|
+
|
|
42
|
+
console.log(`[Runtime] Loading foundation from: ${url}`)
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Load CSS and JS in parallel
|
|
46
|
+
const [, foundation] = await Promise.all([
|
|
47
|
+
cssUrl ? loadFoundationCSS(cssUrl) : Promise.resolve(),
|
|
48
|
+
import(/* @vite-ignore */ url)
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
const componentNames = Object.keys(foundation).filter(k => k !== 'default')
|
|
52
|
+
console.log('[Runtime] Foundation loaded. Available components:', componentNames)
|
|
53
|
+
|
|
54
|
+
return foundation
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('[Runtime] Failed to load foundation:', error)
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load extensions (secondary foundations) in parallel
|
|
63
|
+
* @param {Array<string|Object>} urls - Extension URLs or {url, cssUrl} objects
|
|
64
|
+
* @param {Object} uniwebInstance - The Uniweb instance to register extensions on
|
|
65
|
+
*/
|
|
66
|
+
export async function loadExtensions(urls, uniwebInstance) {
|
|
67
|
+
if (!urls?.length) return
|
|
68
|
+
|
|
69
|
+
// Resolve extension URLs against base path for subdirectory deployments
|
|
70
|
+
// e.g., /effects/foundation.js → /templates/extensions/effects/foundation.js
|
|
71
|
+
const basePath = import.meta.env?.BASE_URL || '/'
|
|
72
|
+
function resolveUrl(source) {
|
|
73
|
+
if (basePath === '/') return source
|
|
74
|
+
if (typeof source === 'string' && source.startsWith('/')) {
|
|
75
|
+
return basePath + source.slice(1)
|
|
76
|
+
}
|
|
77
|
+
if (typeof source === 'object' && source.url?.startsWith('/')) {
|
|
78
|
+
return { ...source, url: basePath + source.url.slice(1) }
|
|
79
|
+
}
|
|
80
|
+
return source
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const results = await Promise.allSettled(
|
|
84
|
+
urls.map(url => loadFoundation(resolveUrl(url)))
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < results.length; i++) {
|
|
88
|
+
if (results[i].status === 'fulfilled') {
|
|
89
|
+
uniwebInstance.registerExtension(results[i].value)
|
|
90
|
+
console.log(`[Runtime] Extension loaded: ${urls[i]}`)
|
|
91
|
+
} else {
|
|
92
|
+
console.warn(`[Runtime] Extension failed to load: ${urls[i]}`, results[i].reason)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/index.jsx
CHANGED
|
@@ -7,307 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import React from 'react'
|
|
9
9
|
import { createRoot } from 'react-dom/client'
|
|
10
|
-
import {
|
|
11
|
-
BrowserRouter,
|
|
12
|
-
Routes,
|
|
13
|
-
Route,
|
|
14
|
-
Link as RouterLink,
|
|
15
|
-
useNavigate,
|
|
16
|
-
useParams,
|
|
17
|
-
useLocation
|
|
18
|
-
} from 'react-router-dom'
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
// Components
|
|
24
|
-
import { ChildBlocks } from './components/PageRenderer.jsx'
|
|
25
|
-
import WebsiteRenderer from './components/WebsiteRenderer.jsx'
|
|
26
|
-
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
|
27
|
-
|
|
28
|
-
// Core factory from @uniweb/core
|
|
29
|
-
import { createUniweb } from '@uniweb/core'
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Decode combined data from __DATA__ element
|
|
33
|
-
*
|
|
34
|
-
* Encoding is signaled via MIME type:
|
|
35
|
-
* - application/json: plain JSON (no compression)
|
|
36
|
-
* - application/gzip: gzip + base64 encoded
|
|
37
|
-
*
|
|
38
|
-
* @returns {Promise<{foundation: Object, content: Object}|null>}
|
|
39
|
-
*/
|
|
40
|
-
async function decodeData() {
|
|
41
|
-
const el = document.getElementById('__DATA__')
|
|
42
|
-
if (!el?.textContent) return null
|
|
43
|
-
|
|
44
|
-
const raw = el.textContent
|
|
45
|
-
|
|
46
|
-
// Plain JSON (uncompressed)
|
|
47
|
-
if (el.type === 'application/json') {
|
|
48
|
-
try {
|
|
49
|
-
return JSON.parse(raw)
|
|
50
|
-
} catch {
|
|
51
|
-
return null
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Compressed (application/gzip or legacy application/octet-stream)
|
|
56
|
-
if (typeof DecompressionStream !== 'undefined') {
|
|
57
|
-
try {
|
|
58
|
-
const bytes = Uint8Array.from(atob(raw), c => c.charCodeAt(0))
|
|
59
|
-
const stream = new DecompressionStream('gzip')
|
|
60
|
-
const writer = stream.writable.getWriter()
|
|
61
|
-
writer.write(bytes)
|
|
62
|
-
writer.close()
|
|
63
|
-
const json = await new Response(stream.readable).text()
|
|
64
|
-
return JSON.parse(json)
|
|
65
|
-
} catch {
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Fallback for old browsers: try plain JSON (server can detect User-Agent)
|
|
71
|
-
try {
|
|
72
|
-
return JSON.parse(raw)
|
|
73
|
-
} catch {
|
|
74
|
-
return null
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Load foundation CSS from URL
|
|
80
|
-
* @param {string} url - URL to foundation's CSS file
|
|
81
|
-
*/
|
|
82
|
-
async function loadFoundationCSS(url) {
|
|
83
|
-
if (!url) return
|
|
84
|
-
|
|
85
|
-
return new Promise((resolve) => {
|
|
86
|
-
const link = document.createElement('link')
|
|
87
|
-
link.rel = 'stylesheet'
|
|
88
|
-
link.href = url
|
|
89
|
-
link.onload = () => {
|
|
90
|
-
console.log('[Runtime] Foundation CSS loaded')
|
|
91
|
-
resolve()
|
|
92
|
-
}
|
|
93
|
-
link.onerror = () => {
|
|
94
|
-
console.warn('[Runtime] Could not load foundation CSS from:', url)
|
|
95
|
-
resolve() // Don't fail for CSS
|
|
96
|
-
}
|
|
97
|
-
document.head.appendChild(link)
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Load a foundation module via dynamic import
|
|
103
|
-
* @param {string|Object} source - URL string or {url, cssUrl} object
|
|
104
|
-
* @returns {Promise<Object>} The loaded foundation module
|
|
105
|
-
*/
|
|
106
|
-
async function loadFoundation(source) {
|
|
107
|
-
const url = typeof source === 'string' ? source : source.url
|
|
108
|
-
// Auto-derive CSS URL from JS URL by convention: foundation.js → assets/foundation.css
|
|
109
|
-
const cssUrl = typeof source === 'object' ? source.cssUrl
|
|
110
|
-
: url.replace(/[^/]+\.js$/, 'assets/foundation.css')
|
|
111
|
-
|
|
112
|
-
console.log(`[Runtime] Loading foundation from: ${url}`)
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
// Load CSS and JS in parallel
|
|
116
|
-
const [, foundation] = await Promise.all([
|
|
117
|
-
cssUrl ? loadFoundationCSS(cssUrl) : Promise.resolve(),
|
|
118
|
-
import(/* @vite-ignore */ url)
|
|
119
|
-
])
|
|
120
|
-
|
|
121
|
-
const componentNames = Object.keys(foundation).filter(k => k !== 'default')
|
|
122
|
-
console.log('[Runtime] Foundation loaded. Available components:', componentNames)
|
|
123
|
-
|
|
124
|
-
return foundation
|
|
125
|
-
} catch (error) {
|
|
126
|
-
console.error('[Runtime] Failed to load foundation:', error)
|
|
127
|
-
throw error
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Load extensions (secondary foundations) in parallel
|
|
133
|
-
* @param {Array<string|Object>} urls - Extension URLs or {url, cssUrl} objects
|
|
134
|
-
* @param {Object} uniwebInstance - The Uniweb instance to register extensions on
|
|
135
|
-
*/
|
|
136
|
-
async function loadExtensions(urls, uniwebInstance) {
|
|
137
|
-
if (!urls?.length) return
|
|
138
|
-
|
|
139
|
-
// Resolve extension URLs against base path for subdirectory deployments
|
|
140
|
-
// e.g., /effects/foundation.js → /templates/extensions/effects/foundation.js
|
|
141
|
-
const basePath = import.meta.env?.BASE_URL || '/'
|
|
142
|
-
function resolveUrl(source) {
|
|
143
|
-
if (basePath === '/') return source
|
|
144
|
-
if (typeof source === 'string' && source.startsWith('/')) {
|
|
145
|
-
return basePath + source.slice(1)
|
|
146
|
-
}
|
|
147
|
-
if (typeof source === 'object' && source.url?.startsWith('/')) {
|
|
148
|
-
return { ...source, url: basePath + source.url.slice(1) }
|
|
149
|
-
}
|
|
150
|
-
return source
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const results = await Promise.allSettled(
|
|
154
|
-
urls.map(url => loadFoundation(resolveUrl(url)))
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
for (let i = 0; i < results.length; i++) {
|
|
158
|
-
if (results[i].status === 'fulfilled') {
|
|
159
|
-
uniwebInstance.registerExtension(results[i].value)
|
|
160
|
-
console.log(`[Runtime] Extension loaded: ${urls[i]}`)
|
|
161
|
-
} else {
|
|
162
|
-
console.warn(`[Runtime] Extension failed to load: ${urls[i]}`, results[i].reason)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Map friendly family names to react-icons codes
|
|
169
|
-
* The existing CDN uses react-icons structure: /{familyCode}/{familyCode}-{name}.svg
|
|
170
|
-
*/
|
|
171
|
-
const ICON_FAMILY_MAP = {
|
|
172
|
-
// Friendly names
|
|
173
|
-
lucide: 'lu',
|
|
174
|
-
heroicons: 'hi',
|
|
175
|
-
heroicons2: 'hi2',
|
|
176
|
-
phosphor: 'pi',
|
|
177
|
-
tabler: 'tb',
|
|
178
|
-
feather: 'fi',
|
|
179
|
-
// Font Awesome (multiple versions)
|
|
180
|
-
fa: 'fa',
|
|
181
|
-
fa6: 'fa6',
|
|
182
|
-
// Additional families from react-icons
|
|
183
|
-
bootstrap: 'bs',
|
|
184
|
-
'material-design': 'md',
|
|
185
|
-
'ant-design': 'ai',
|
|
186
|
-
remix: 'ri',
|
|
187
|
-
'simple-icons': 'si',
|
|
188
|
-
ionicons: 'io5',
|
|
189
|
-
boxicons: 'bi',
|
|
190
|
-
vscode: 'vsc',
|
|
191
|
-
weather: 'wi',
|
|
192
|
-
game: 'gi',
|
|
193
|
-
// Also support direct codes for power users
|
|
194
|
-
lu: 'lu',
|
|
195
|
-
hi: 'hi',
|
|
196
|
-
hi2: 'hi2',
|
|
197
|
-
pi: 'pi',
|
|
198
|
-
tb: 'tb',
|
|
199
|
-
fi: 'fi',
|
|
200
|
-
bs: 'bs',
|
|
201
|
-
md: 'md',
|
|
202
|
-
ai: 'ai',
|
|
203
|
-
ri: 'ri',
|
|
204
|
-
io5: 'io5',
|
|
205
|
-
bi: 'bi',
|
|
206
|
-
si: 'si',
|
|
207
|
-
vsc: 'vsc',
|
|
208
|
-
wi: 'wi',
|
|
209
|
-
gi: 'gi'
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Create CDN-based icon resolver
|
|
214
|
-
* @param {Object} iconConfig - From site.yml icons:
|
|
215
|
-
* @returns {Function} Resolver: (library, name) => Promise<string|null>
|
|
216
|
-
*/
|
|
217
|
-
function createIconResolver(iconConfig = {}) {
|
|
218
|
-
// Default to GitHub Pages CDN, can be overridden in site.yml
|
|
219
|
-
const CDN_BASE = iconConfig.cdnUrl || 'https://uniweb.github.io/icons'
|
|
220
|
-
const useCdn = iconConfig.cdn !== false
|
|
221
|
-
|
|
222
|
-
// Cache resolved icons
|
|
223
|
-
const cache = new Map()
|
|
224
|
-
|
|
225
|
-
return async function resolve(library, name) {
|
|
226
|
-
// Map friendly name to react-icons code
|
|
227
|
-
const familyCode = ICON_FAMILY_MAP[library.toLowerCase()]
|
|
228
|
-
if (!familyCode) {
|
|
229
|
-
console.warn(`[icons] Unknown family "${library}"`)
|
|
230
|
-
return null
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Check cache
|
|
234
|
-
const key = `${familyCode}:${name}`
|
|
235
|
-
if (cache.has(key)) return cache.get(key)
|
|
236
|
-
|
|
237
|
-
// Fetch from CDN
|
|
238
|
-
if (!useCdn) {
|
|
239
|
-
cache.set(key, null)
|
|
240
|
-
return null
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
// CDN structure: /{familyCode}/{familyCode}-{name}.svg
|
|
245
|
-
// e.g., lucide:home → /lu/lu-home.svg
|
|
246
|
-
const iconFileName = `${familyCode}-${name}`
|
|
247
|
-
const url = `${CDN_BASE}/${familyCode}/${iconFileName}.svg`
|
|
248
|
-
const response = await fetch(url)
|
|
249
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
250
|
-
const svg = await response.text()
|
|
251
|
-
cache.set(key, svg)
|
|
252
|
-
return svg
|
|
253
|
-
} catch (err) {
|
|
254
|
-
console.warn(`[icons] Failed to load ${library}:${name}`, err.message)
|
|
255
|
-
cache.set(key, null)
|
|
256
|
-
return null
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Initialize the Uniweb instance
|
|
263
|
-
* @param {Object} configData - Site configuration data
|
|
264
|
-
* @returns {Uniweb}
|
|
265
|
-
*/
|
|
266
|
-
function initUniweb(configData) {
|
|
267
|
-
// Create singleton via @uniweb/core (also assigns to globalThis.uniweb)
|
|
268
|
-
const uniwebInstance = createUniweb(configData)
|
|
269
|
-
|
|
270
|
-
// Pre-populate DataStore from build-time fetched data
|
|
271
|
-
if (configData.fetchedData && uniwebInstance.activeWebsite?.dataStore) {
|
|
272
|
-
for (const entry of configData.fetchedData) {
|
|
273
|
-
uniwebInstance.activeWebsite.dataStore.set(entry.config, entry.data)
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Set up child block renderer for nested blocks
|
|
278
|
-
uniwebInstance.childBlockRenderer = ChildBlocks
|
|
279
|
-
|
|
280
|
-
// Register routing components for kit and foundation components
|
|
281
|
-
// This enables the bridge pattern: components access routing via
|
|
282
|
-
// website.getRoutingComponents() instead of direct imports
|
|
283
|
-
uniwebInstance.routingComponents = {
|
|
284
|
-
Link: RouterLink,
|
|
285
|
-
useNavigate,
|
|
286
|
-
useParams,
|
|
287
|
-
useLocation
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Set up icon resolver based on site config
|
|
291
|
-
uniwebInstance.iconResolver = createIconResolver(configData.icons)
|
|
292
|
-
|
|
293
|
-
// Populate icon cache from prerendered data (if available)
|
|
294
|
-
// This allows icons to render immediately without CDN fetches
|
|
295
|
-
if (typeof document !== 'undefined') {
|
|
296
|
-
try {
|
|
297
|
-
const cacheEl = document.getElementById('__ICON_CACHE__')
|
|
298
|
-
if (cacheEl) {
|
|
299
|
-
const cached = JSON.parse(cacheEl.textContent)
|
|
300
|
-
for (const [key, svg] of Object.entries(cached)) {
|
|
301
|
-
uniwebInstance.iconCache.set(key, svg)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
} catch (e) {
|
|
305
|
-
// Ignore parse errors
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
return uniwebInstance
|
|
310
|
-
}
|
|
11
|
+
import { setupUniweb, registerFoundation, decodeData } from './setup.js'
|
|
12
|
+
import { loadFoundation, loadExtensions } from './foundation-loader.js'
|
|
13
|
+
import RuntimeProvider from './RuntimeProvider.jsx'
|
|
311
14
|
|
|
312
15
|
/**
|
|
313
16
|
* Get the router basename from Vite's BASE_URL
|
|
@@ -321,63 +24,6 @@ function getBasename() {
|
|
|
321
24
|
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
|
|
322
25
|
}
|
|
323
26
|
|
|
324
|
-
/**
|
|
325
|
-
* Render the application
|
|
326
|
-
* @param {Object} options
|
|
327
|
-
*/
|
|
328
|
-
function render({ development = false, basename } = {}) {
|
|
329
|
-
const container = document.getElementById('root')
|
|
330
|
-
if (!container) {
|
|
331
|
-
console.error('[Runtime] Root element not found')
|
|
332
|
-
return
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Use provided basename, or derive from Vite's BASE_URL
|
|
336
|
-
const routerBasename = basename ?? getBasename()
|
|
337
|
-
|
|
338
|
-
// Set initial active page from browser URL so getLocaleUrl() works on first render
|
|
339
|
-
const website = globalThis.uniweb?.activeWebsite
|
|
340
|
-
if (website && typeof window !== 'undefined') {
|
|
341
|
-
const rawPath = window.location.pathname
|
|
342
|
-
const basePath = routerBasename || ''
|
|
343
|
-
const routePath = basePath && rawPath.startsWith(basePath)
|
|
344
|
-
? rawPath.slice(basePath.length) || '/'
|
|
345
|
-
: rawPath
|
|
346
|
-
website.setActivePage(routePath)
|
|
347
|
-
|
|
348
|
-
// Store base path on Website for components that need it (e.g., Link reload)
|
|
349
|
-
if (website.setBasePath) {
|
|
350
|
-
website.setBasePath(routerBasename || '')
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Register data fetcher on the DataStore so BlockRenderer can use it
|
|
354
|
-
if (website.dataStore) {
|
|
355
|
-
website.dataStore.registerFetcher(executeFetchClient)
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const root = createRoot(container)
|
|
360
|
-
|
|
361
|
-
const app = (
|
|
362
|
-
<ErrorBoundary
|
|
363
|
-
fallback={
|
|
364
|
-
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
365
|
-
<h2>Something went wrong</h2>
|
|
366
|
-
<p>Please try refreshing the page</p>
|
|
367
|
-
</div>
|
|
368
|
-
}
|
|
369
|
-
>
|
|
370
|
-
<BrowserRouter basename={routerBasename}>
|
|
371
|
-
<Routes>
|
|
372
|
-
<Route path="/*" element={<WebsiteRenderer />} />
|
|
373
|
-
</Routes>
|
|
374
|
-
</BrowserRouter>
|
|
375
|
-
</ErrorBoundary>
|
|
376
|
-
)
|
|
377
|
-
|
|
378
|
-
root.render(development ? <React.StrictMode>{app}</React.StrictMode> : app)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
27
|
/**
|
|
382
28
|
* Initialize the Runtime Environment
|
|
383
29
|
*
|
|
@@ -409,7 +55,7 @@ async function initRuntime(foundationSource, options = {}) {
|
|
|
409
55
|
}
|
|
410
56
|
|
|
411
57
|
// Initialize core runtime
|
|
412
|
-
const uniwebInstance =
|
|
58
|
+
const uniwebInstance = setupUniweb(configData)
|
|
413
59
|
|
|
414
60
|
try {
|
|
415
61
|
let foundation
|
|
@@ -432,18 +78,8 @@ async function initRuntime(foundationSource, options = {}) {
|
|
|
432
78
|
throw new Error('Failed to load foundation')
|
|
433
79
|
}
|
|
434
80
|
|
|
435
|
-
//
|
|
436
|
-
uniwebInstance
|
|
437
|
-
|
|
438
|
-
// Set foundation capabilities (layouts, props, etc.) if provided
|
|
439
|
-
if (foundation.default?.capabilities) {
|
|
440
|
-
uniwebInstance.setFoundationConfig(foundation.default.capabilities)
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Attach layout metadata (areas, transitions, defaults) from foundation entry point
|
|
444
|
-
if (foundation.default?.layoutMeta && uniwebInstance.foundationConfig) {
|
|
445
|
-
uniwebInstance.foundationConfig.layoutMeta = foundation.default.layoutMeta
|
|
446
|
-
}
|
|
81
|
+
// Register foundation on the runtime
|
|
82
|
+
registerFoundation(uniwebInstance, foundation)
|
|
447
83
|
|
|
448
84
|
// Load extensions (secondary foundations)
|
|
449
85
|
const extensions = configData?.config?.extensions
|
|
@@ -451,8 +87,31 @@ async function initRuntime(foundationSource, options = {}) {
|
|
|
451
87
|
await loadExtensions(extensions, uniwebInstance)
|
|
452
88
|
}
|
|
453
89
|
|
|
90
|
+
// Derive basename
|
|
91
|
+
const routerBasename = basename ?? getBasename()
|
|
92
|
+
|
|
93
|
+
// Set initial active page from browser URL so getLocaleUrl() works on first render
|
|
94
|
+
const website = uniwebInstance.activeWebsite
|
|
95
|
+
if (website && typeof window !== 'undefined') {
|
|
96
|
+
const rawPath = window.location.pathname
|
|
97
|
+
const basePath = routerBasename || ''
|
|
98
|
+
const routePath = basePath && rawPath.startsWith(basePath)
|
|
99
|
+
? rawPath.slice(basePath.length) || '/'
|
|
100
|
+
: rawPath
|
|
101
|
+
website.setActivePage(routePath)
|
|
102
|
+
}
|
|
103
|
+
|
|
454
104
|
// Render the app
|
|
455
|
-
|
|
105
|
+
const container = document.getElementById('root')
|
|
106
|
+
if (!container) {
|
|
107
|
+
console.error('[Runtime] Root element not found')
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const root = createRoot(container)
|
|
112
|
+
root.render(
|
|
113
|
+
<RuntimeProvider basename={routerBasename} development={development} />
|
|
114
|
+
)
|
|
456
115
|
|
|
457
116
|
// Log success
|
|
458
117
|
if (!development) {
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime setup — singleton initialization and data decoding
|
|
3
|
+
*
|
|
4
|
+
* Creates and configures the Uniweb singleton (globalThis.uniweb)
|
|
5
|
+
* with routing components, icon resolver, data fetcher, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createUniweb, Website } from '@uniweb/core'
|
|
9
|
+
import {
|
|
10
|
+
Link as RouterLink,
|
|
11
|
+
useNavigate,
|
|
12
|
+
useParams,
|
|
13
|
+
useLocation
|
|
14
|
+
} from 'react-router-dom'
|
|
15
|
+
|
|
16
|
+
import { ChildBlocks } from './components/PageRenderer.jsx'
|
|
17
|
+
import { executeFetchClient } from './data-fetcher-client.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Map friendly family names to react-icons codes
|
|
21
|
+
* The existing CDN uses react-icons structure: /{familyCode}/{familyCode}-{name}.svg
|
|
22
|
+
*/
|
|
23
|
+
const ICON_FAMILY_MAP = {
|
|
24
|
+
// Friendly names
|
|
25
|
+
lucide: 'lu',
|
|
26
|
+
heroicons: 'hi',
|
|
27
|
+
heroicons2: 'hi2',
|
|
28
|
+
phosphor: 'pi',
|
|
29
|
+
tabler: 'tb',
|
|
30
|
+
feather: 'fi',
|
|
31
|
+
// Font Awesome (multiple versions)
|
|
32
|
+
fa: 'fa',
|
|
33
|
+
fa6: 'fa6',
|
|
34
|
+
// Additional families from react-icons
|
|
35
|
+
bootstrap: 'bs',
|
|
36
|
+
'material-design': 'md',
|
|
37
|
+
'ant-design': 'ai',
|
|
38
|
+
remix: 'ri',
|
|
39
|
+
'simple-icons': 'si',
|
|
40
|
+
ionicons: 'io5',
|
|
41
|
+
boxicons: 'bi',
|
|
42
|
+
vscode: 'vsc',
|
|
43
|
+
weather: 'wi',
|
|
44
|
+
game: 'gi',
|
|
45
|
+
// Also support direct codes for power users
|
|
46
|
+
lu: 'lu',
|
|
47
|
+
hi: 'hi',
|
|
48
|
+
hi2: 'hi2',
|
|
49
|
+
pi: 'pi',
|
|
50
|
+
tb: 'tb',
|
|
51
|
+
fi: 'fi',
|
|
52
|
+
bs: 'bs',
|
|
53
|
+
md: 'md',
|
|
54
|
+
ai: 'ai',
|
|
55
|
+
ri: 'ri',
|
|
56
|
+
io5: 'io5',
|
|
57
|
+
bi: 'bi',
|
|
58
|
+
si: 'si',
|
|
59
|
+
vsc: 'vsc',
|
|
60
|
+
wi: 'wi',
|
|
61
|
+
gi: 'gi'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create CDN-based icon resolver
|
|
66
|
+
* @param {Object} iconConfig - From site.yml icons:
|
|
67
|
+
* @returns {Function} Resolver: (library, name) => Promise<string|null>
|
|
68
|
+
*/
|
|
69
|
+
function createIconResolver(iconConfig = {}) {
|
|
70
|
+
// Default to GitHub Pages CDN, can be overridden in site.yml
|
|
71
|
+
const CDN_BASE = iconConfig.cdnUrl || 'https://uniweb.github.io/icons'
|
|
72
|
+
const useCdn = iconConfig.cdn !== false
|
|
73
|
+
|
|
74
|
+
// Cache resolved icons
|
|
75
|
+
const cache = new Map()
|
|
76
|
+
|
|
77
|
+
return async function resolve(library, name) {
|
|
78
|
+
// Map friendly name to react-icons code
|
|
79
|
+
const familyCode = ICON_FAMILY_MAP[library.toLowerCase()]
|
|
80
|
+
if (!familyCode) {
|
|
81
|
+
console.warn(`[icons] Unknown family "${library}"`)
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check cache
|
|
86
|
+
const key = `${familyCode}:${name}`
|
|
87
|
+
if (cache.has(key)) return cache.get(key)
|
|
88
|
+
|
|
89
|
+
// Fetch from CDN
|
|
90
|
+
if (!useCdn) {
|
|
91
|
+
cache.set(key, null)
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// CDN structure: /{familyCode}/{familyCode}-{name}.svg
|
|
97
|
+
// e.g., lucide:home → /lu/lu-home.svg
|
|
98
|
+
const iconFileName = `${familyCode}-${name}`
|
|
99
|
+
const url = `${CDN_BASE}/${familyCode}/${iconFileName}.svg`
|
|
100
|
+
const response = await fetch(url)
|
|
101
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
102
|
+
const svg = await response.text()
|
|
103
|
+
cache.set(key, svg)
|
|
104
|
+
return svg
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.warn(`[icons] Failed to load ${library}:${name}`, err.message)
|
|
107
|
+
cache.set(key, null)
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Initialize the Uniweb singleton.
|
|
115
|
+
*
|
|
116
|
+
* Creates globalThis.uniweb with Website, routing components, icons,
|
|
117
|
+
* data fetcher, and pre-populated DataStore.
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} configData - Site configuration data
|
|
120
|
+
* @returns {Uniweb}
|
|
121
|
+
*/
|
|
122
|
+
export function setupUniweb(configData) {
|
|
123
|
+
// Create singleton via @uniweb/core (also assigns to globalThis.uniweb)
|
|
124
|
+
const uniwebInstance = createUniweb(configData)
|
|
125
|
+
|
|
126
|
+
// Pre-populate DataStore from build-time fetched data
|
|
127
|
+
if (configData.fetchedData && uniwebInstance.activeWebsite?.dataStore) {
|
|
128
|
+
for (const entry of configData.fetchedData) {
|
|
129
|
+
uniwebInstance.activeWebsite.dataStore.set(entry.config, entry.data)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Set up child block renderer for nested blocks
|
|
134
|
+
uniwebInstance.childBlockRenderer = ChildBlocks
|
|
135
|
+
|
|
136
|
+
// Register routing components for kit and foundation components
|
|
137
|
+
// This enables the bridge pattern: components access routing via
|
|
138
|
+
// website.getRoutingComponents() instead of direct imports
|
|
139
|
+
uniwebInstance.routingComponents = {
|
|
140
|
+
Link: RouterLink,
|
|
141
|
+
useNavigate,
|
|
142
|
+
useParams,
|
|
143
|
+
useLocation
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Set up icon resolver based on site config
|
|
147
|
+
uniwebInstance.iconResolver = createIconResolver(configData.icons)
|
|
148
|
+
|
|
149
|
+
// Populate icon cache from prerendered data (if available)
|
|
150
|
+
// This allows icons to render immediately without CDN fetches
|
|
151
|
+
if (typeof document !== 'undefined') {
|
|
152
|
+
try {
|
|
153
|
+
const cacheEl = document.getElementById('__ICON_CACHE__')
|
|
154
|
+
if (cacheEl) {
|
|
155
|
+
const cached = JSON.parse(cacheEl.textContent)
|
|
156
|
+
for (const [key, svg] of Object.entries(cached)) {
|
|
157
|
+
uniwebInstance.iconCache.set(key, svg)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (e) {
|
|
161
|
+
// Ignore parse errors
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Register data fetcher on DataStore so BlockRenderer can fetch data
|
|
166
|
+
if (uniwebInstance.activeWebsite?.dataStore) {
|
|
167
|
+
uniwebInstance.activeWebsite.dataStore.registerFetcher(executeFetchClient)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return uniwebInstance
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Rebuild the Website within the existing singleton.
|
|
175
|
+
*
|
|
176
|
+
* For live editing: creates a new Website from modified configData
|
|
177
|
+
* and assigns it to the singleton. The singleton itself (foundation,
|
|
178
|
+
* icon resolver, routing components) stays unchanged.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} configData - Modified site configuration data
|
|
181
|
+
* @returns {Website} The new Website instance
|
|
182
|
+
*/
|
|
183
|
+
export function rebuildWebsite(configData) {
|
|
184
|
+
const uniweb = globalThis.uniweb
|
|
185
|
+
const newWebsite = new Website(configData)
|
|
186
|
+
newWebsite.dataStore.registerFetcher(executeFetchClient)
|
|
187
|
+
uniweb.activeWebsite = newWebsite
|
|
188
|
+
return newWebsite
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register a foundation on the Uniweb singleton.
|
|
193
|
+
*
|
|
194
|
+
* Separated from setupUniweb because foundation loading is async
|
|
195
|
+
* and happens independently.
|
|
196
|
+
*
|
|
197
|
+
* @param {Object} uniwebInstance - The Uniweb singleton
|
|
198
|
+
* @param {Object} foundation - The loaded foundation module
|
|
199
|
+
*/
|
|
200
|
+
export function registerFoundation(uniwebInstance, foundation) {
|
|
201
|
+
uniwebInstance.setFoundation(foundation)
|
|
202
|
+
|
|
203
|
+
if (foundation.default?.capabilities) {
|
|
204
|
+
uniwebInstance.setFoundationConfig(foundation.default.capabilities)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (foundation.default?.layoutMeta && uniwebInstance.foundationConfig) {
|
|
208
|
+
uniwebInstance.foundationConfig.layoutMeta = foundation.default.layoutMeta
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Decode combined data from __DATA__ element
|
|
214
|
+
*
|
|
215
|
+
* Encoding is signaled via MIME type:
|
|
216
|
+
* - application/json: plain JSON (no compression)
|
|
217
|
+
* - application/gzip: gzip + base64 encoded
|
|
218
|
+
*
|
|
219
|
+
* @returns {Promise<{foundation: Object, content: Object}|null>}
|
|
220
|
+
*/
|
|
221
|
+
export async function decodeData() {
|
|
222
|
+
const el = document.getElementById('__DATA__')
|
|
223
|
+
if (!el?.textContent) return null
|
|
224
|
+
|
|
225
|
+
const raw = el.textContent
|
|
226
|
+
|
|
227
|
+
// Plain JSON (uncompressed)
|
|
228
|
+
if (el.type === 'application/json') {
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(raw)
|
|
231
|
+
} catch {
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Compressed (application/gzip or legacy application/octet-stream)
|
|
237
|
+
if (typeof DecompressionStream !== 'undefined') {
|
|
238
|
+
try {
|
|
239
|
+
const bytes = Uint8Array.from(atob(raw), c => c.charCodeAt(0))
|
|
240
|
+
const stream = new DecompressionStream('gzip')
|
|
241
|
+
const writer = stream.writable.getWriter()
|
|
242
|
+
writer.write(bytes)
|
|
243
|
+
writer.close()
|
|
244
|
+
const json = await new Response(stream.readable).text()
|
|
245
|
+
return JSON.parse(json)
|
|
246
|
+
} catch {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Fallback for old browsers: try plain JSON (server can detect User-Agent)
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(raw)
|
|
254
|
+
} catch {
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
}
|