@uniweb/runtime 0.6.9 → 0.6.11

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,11 +1,14 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
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.9"
37
+ "@uniweb/core": "0.5.10"
35
38
  },
36
39
  "devDependencies": {
37
40
  "@vitejs/plugin-react": "^4.5.2",
@@ -0,0 +1,52 @@
1
+ /**
2
+ * RuntimeProvider
3
+ *
4
+ * Encapsulates the full React rendering tree for a Uniweb site:
5
+ * ErrorBoundary → Router → 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
+ * @param {boolean} [props.externalRouter] - Skip creating a BrowserRouter — the
16
+ * consumer wraps RuntimeProvider in their own Router (e.g., MemoryRouter for
17
+ * srcdoc iframes). RuntimeProvider still renders ErrorBoundary, Routes, and
18
+ * WebsiteRenderer.
19
+ */
20
+
21
+ import React from 'react'
22
+ import { BrowserRouter, Routes, Route } from 'react-router-dom'
23
+ import WebsiteRenderer from './components/WebsiteRenderer.jsx'
24
+ import ErrorBoundary from './components/ErrorBoundary.jsx'
25
+
26
+ export default function RuntimeProvider({ basename, development = false, externalRouter = false }) {
27
+ const website = globalThis.uniweb?.activeWebsite
28
+ if (!website) return null
29
+
30
+ // Set basePath for subdirectory deployments
31
+ if (website.setBasePath) {
32
+ website.setBasePath(basename || '')
33
+ }
34
+
35
+ const routes = (
36
+ <Routes>
37
+ <Route path="/*" element={<WebsiteRenderer />} />
38
+ </Routes>
39
+ )
40
+
41
+ const app = externalRouter
42
+ ? <ErrorBoundary>{routes}</ErrorBoundary>
43
+ : (
44
+ <ErrorBoundary>
45
+ <BrowserRouter basename={basename}>
46
+ {routes}
47
+ </BrowserRouter>
48
+ </ErrorBoundary>
49
+ )
50
+
51
+ return development ? <React.StrictMode>{app}</React.StrictMode> : app
52
+ }
@@ -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
- // Data fetcher (registered on dataStore so core can call it without importing runtime)
21
- import { executeFetchClient } from './data-fetcher-client.js'
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 = initUniweb(configData)
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
- // Set the foundation on the runtime
436
- uniwebInstance.setFoundation(foundation)
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
- render({ development, basename })
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) {
@@ -514,9 +173,12 @@ async function start({ config, foundation, styles } = {}) {
514
173
 
515
174
  if (data) {
516
175
  // Dynamic backend mode - foundation loaded from URL, content from data
176
+ // The serving layer may inject config.base for subdirectory deployments
177
+ const base = data.content?.config?.base
178
+ const basename = base ? (base.endsWith('/') ? base.slice(0, -1) : base) : undefined
517
179
  return initRuntime(
518
180
  { url: data.foundation.url, cssUrl: data.foundation.cssUrl },
519
- { configData: data.content }
181
+ { configData: data.content, basename }
520
182
  )
521
183
  }
522
184
 
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
+ }