@uniweb/runtime 0.1.0

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Website
3
+ *
4
+ * Manages pages, themes, and localization for a website instance.
5
+ */
6
+
7
+ import Page from './page.js'
8
+
9
+ export default class Website {
10
+ constructor(websiteData) {
11
+ const { pages = [], theme = {}, config = {} } = websiteData
12
+
13
+ // Extract special pages (header, footer) and regular pages
14
+ this.headerPage = pages.find((p) => p.route === '/@header')
15
+ this.footerPage = pages.find((p) => p.route === '/@footer')
16
+
17
+ this.pages = pages
18
+ .filter((page) => page.route !== '/@header' && page.route !== '/@footer')
19
+ .map(
20
+ (page, index) =>
21
+ new Page(page, index, this.headerPage, this.footerPage)
22
+ )
23
+
24
+ this.activePage =
25
+ this.pages.find((page) => page.route === '/' || page.route === '/index') ||
26
+ this.pages[0]
27
+
28
+ this.pageRoutes = this.pages.map((page) => page.route)
29
+ this.themeData = theme
30
+ this.config = config
31
+ this.activeLang = config.defaultLanguage || 'en'
32
+ this.langs = config.languages || [
33
+ { label: 'English', value: 'en' },
34
+ { label: 'français', value: 'fr' }
35
+ ]
36
+ }
37
+
38
+ /**
39
+ * Get page by route
40
+ * @param {string} route
41
+ * @returns {Page|undefined}
42
+ */
43
+ getPage(route) {
44
+ return this.pages.find((page) => page.route === route)
45
+ }
46
+
47
+ /**
48
+ * Set active page by route
49
+ * @param {string} route
50
+ */
51
+ setActivePage(route) {
52
+ const page = this.getPage(route)
53
+ if (page) {
54
+ this.activePage = page
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get remote layout component from foundation config
60
+ */
61
+ getRemoteLayout() {
62
+ return globalThis.uniweb?.foundationConfig?.Layout || null
63
+ }
64
+
65
+ /**
66
+ * Get remote props from foundation config
67
+ */
68
+ getRemoteProps() {
69
+ return globalThis.uniweb?.foundationConfig?.props || null
70
+ }
71
+
72
+ /**
73
+ * Get routing components (Link, useNavigate, etc.)
74
+ */
75
+ getRoutingComponents() {
76
+ return globalThis.uniweb?.routingComponents || {}
77
+ }
78
+
79
+ /**
80
+ * Make href (for link transformation)
81
+ * @param {string} href
82
+ * @returns {string}
83
+ */
84
+ makeHref(href) {
85
+ // Could add basename handling here
86
+ return href
87
+ }
88
+
89
+ /**
90
+ * Get available languages
91
+ */
92
+ getLanguages() {
93
+ return this.langs
94
+ }
95
+
96
+ /**
97
+ * Get current language
98
+ */
99
+ getLanguage() {
100
+ return this.activeLang
101
+ }
102
+
103
+ /**
104
+ * Localize a value
105
+ * @param {any} val - Value to localize (object with lang keys, or string)
106
+ * @param {string} defaultVal - Default value if not found
107
+ * @param {string} givenLang - Override language
108
+ * @param {boolean} fallbackDefaultLangVal - Fall back to default language
109
+ * @returns {string}
110
+ */
111
+ localize(val, defaultVal = '', givenLang = '', fallbackDefaultLangVal = false) {
112
+ const lang = givenLang || this.activeLang
113
+ const defaultLang = this.langs[0]?.value || 'en'
114
+
115
+ if (typeof val === 'object' && !Array.isArray(val)) {
116
+ return fallbackDefaultLangVal
117
+ ? val?.[lang] || val?.[defaultLang] || defaultVal
118
+ : val?.[lang] || defaultVal
119
+ }
120
+
121
+ if (typeof val === 'string') {
122
+ if (!val.startsWith('{') && !val.startsWith('"')) return val
123
+
124
+ try {
125
+ const obj = JSON.parse(val)
126
+ if (typeof obj === 'object') {
127
+ return fallbackDefaultLangVal
128
+ ? obj?.[lang] || obj?.[defaultLang] || defaultVal
129
+ : obj?.[lang] || defaultVal
130
+ }
131
+ return obj
132
+ } catch {
133
+ return val
134
+ }
135
+ }
136
+
137
+ return defaultVal
138
+ }
139
+
140
+ /**
141
+ * Get search data for all pages
142
+ */
143
+ getSearchData() {
144
+ return this.pages.map((page) => ({
145
+ id: page.id,
146
+ title: page.title,
147
+ href: page.route,
148
+ route: page.route,
149
+ description: page.description,
150
+ content: page
151
+ .getPageBlocks()
152
+ .map((b) => b.title)
153
+ .filter(Boolean)
154
+ .join('\n')
155
+ }))
156
+ }
157
+ }
package/src/index.js ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @uniweb/runtime - Main Entry Point
3
+ *
4
+ * This is the Vite/ESM-based runtime that loads foundations via dynamic import()
5
+ * instead of Webpack Module Federation.
6
+ */
7
+
8
+ import React from 'react'
9
+ import { createRoot } from 'react-dom/client'
10
+ import { BrowserRouter, Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom'
11
+
12
+ // Components
13
+ import { ChildBlocks } from './components/PageRenderer.jsx'
14
+ import Link from './components/Link.jsx'
15
+ import SafeHtml from './components/SafeHtml.jsx'
16
+ import WebsiteRenderer from './components/WebsiteRenderer.jsx'
17
+ import ErrorBoundary from './components/ErrorBoundary.jsx'
18
+
19
+ // Core
20
+ import Uniweb from './core/uniweb.js'
21
+
22
+ /**
23
+ * Load foundation CSS from URL
24
+ * @param {string} url - URL to foundation's CSS file
25
+ */
26
+ async function loadFoundationCSS(url) {
27
+ if (!url) return
28
+
29
+ return new Promise((resolve) => {
30
+ const link = document.createElement('link')
31
+ link.rel = 'stylesheet'
32
+ link.href = url
33
+ link.onload = () => {
34
+ console.log('[Runtime] Foundation CSS loaded')
35
+ resolve()
36
+ }
37
+ link.onerror = () => {
38
+ console.warn('[Runtime] Could not load foundation CSS from:', url)
39
+ resolve() // Don't fail for CSS
40
+ }
41
+ document.head.appendChild(link)
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Load a foundation module via dynamic import
47
+ * @param {string|Object} source - URL string or {url, cssUrl} object
48
+ * @returns {Promise<Object>} The loaded foundation module
49
+ */
50
+ async function loadFoundation(source) {
51
+ const url = typeof source === 'string' ? source : source.url
52
+ const cssUrl = typeof source === 'object' ? source.cssUrl : null
53
+
54
+ console.log(`[Runtime] Loading foundation from: ${url}`)
55
+
56
+ try {
57
+ // Load CSS and JS in parallel
58
+ const [, foundation] = await Promise.all([
59
+ cssUrl ? loadFoundationCSS(cssUrl) : Promise.resolve(),
60
+ import(/* @vite-ignore */ url)
61
+ ])
62
+
63
+ console.log('[Runtime] Foundation loaded. Available components:',
64
+ typeof foundation.listComponents === 'function' ? foundation.listComponents() : 'unknown')
65
+
66
+ return foundation
67
+ } catch (error) {
68
+ console.error('[Runtime] Failed to load foundation:', error)
69
+ throw error
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Initialize the Uniweb instance
75
+ * @param {Object} configData - Site configuration data
76
+ * @returns {Uniweb}
77
+ */
78
+ function initUniweb(configData) {
79
+ const uniwebInstance = new Uniweb(configData)
80
+
81
+ // Global assignment for component access
82
+ globalThis.uniweb = uniwebInstance
83
+
84
+ // Set up child block renderer
85
+ uniwebInstance.childBlockRenderer = ChildBlocks
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
+ return uniwebInstance
97
+ }
98
+
99
+ /**
100
+ * Render the application
101
+ * @param {Object} options
102
+ */
103
+ function render({ development = false, basename } = {}) {
104
+ const container = document.getElementById('root')
105
+ if (!container) {
106
+ console.error('[Runtime] Root element not found')
107
+ return
108
+ }
109
+
110
+ const root = createRoot(container)
111
+
112
+ const app = (
113
+ <ErrorBoundary
114
+ fallback={
115
+ <div style={{ padding: '2rem', textAlign: 'center' }}>
116
+ <h2>Something went wrong</h2>
117
+ <p>Please try refreshing the page</p>
118
+ </div>
119
+ }
120
+ >
121
+ <BrowserRouter basename={basename}>
122
+ <Routes>
123
+ <Route path="/*" element={<WebsiteRenderer />} />
124
+ </Routes>
125
+ </BrowserRouter>
126
+ </ErrorBoundary>
127
+ )
128
+
129
+ root.render(development ? <React.StrictMode>{app}</React.StrictMode> : app)
130
+ }
131
+
132
+ /**
133
+ * Initialize the Runtime Environment
134
+ *
135
+ * @param {string|Object|Promise} foundationSource - One of:
136
+ * - URL string to foundation module
137
+ * - Object with {url, cssUrl}
138
+ * - Promise that resolves to a foundation module (legacy federation support)
139
+ * @param {Object} options
140
+ * @param {boolean} options.development - Enable development mode
141
+ * @param {Object} options.configData - Site configuration (or read from DOM)
142
+ * @param {string} options.basename - Router basename
143
+ */
144
+ async function initRuntime(foundationSource, options = {}) {
145
+ const {
146
+ development = import.meta.env?.DEV ?? false,
147
+ configData: providedConfig = null,
148
+ basename
149
+ } = options
150
+
151
+ // Get config data from options, DOM, or global
152
+ const configData = providedConfig
153
+ ?? JSON.parse(document.getElementById('__SITE_CONTENT__')?.textContent || 'null')
154
+ ?? globalThis.__SITE_CONTENT__
155
+
156
+ if (!configData) {
157
+ console.error('[Runtime] No site configuration found')
158
+ return
159
+ }
160
+
161
+ // Initialize core runtime
162
+ const uniwebInstance = initUniweb(configData)
163
+
164
+ try {
165
+ let foundation
166
+
167
+ // Handle different foundation source types
168
+ if (typeof foundationSource === 'string' || (foundationSource && typeof foundationSource.url === 'string')) {
169
+ // ESM URL - load via dynamic import
170
+ foundation = await loadFoundation(foundationSource)
171
+ } else if (foundationSource && typeof foundationSource.then === 'function') {
172
+ // Promise (legacy Module Federation support)
173
+ const remoteModule = await foundationSource
174
+ // Handle double default wrapping
175
+ const innerModule = remoteModule?.default?.default ? remoteModule.default : remoteModule
176
+ // Convert to foundation interface
177
+ foundation = {
178
+ getComponent: (name) => innerModule.default?.[name],
179
+ listComponents: () => Object.keys(innerModule.default || {}),
180
+ ...innerModule
181
+ }
182
+ } else if (foundationSource && typeof foundationSource === 'object') {
183
+ // Already a foundation module
184
+ foundation = foundationSource
185
+ }
186
+
187
+ if (!foundation) {
188
+ throw new Error('Failed to load foundation')
189
+ }
190
+
191
+ // Set the foundation on the runtime
192
+ uniwebInstance.setFoundation(foundation)
193
+
194
+ // Set foundation config if provided
195
+ if (foundation.config || foundation.site) {
196
+ uniwebInstance.setFoundationConfig(foundation.config || foundation.site)
197
+ }
198
+
199
+ // Render the app
200
+ render({ development, basename })
201
+
202
+ // Log success
203
+ if (!development) {
204
+ console.log(
205
+ '%c<%c>%c Uniweb Runtime',
206
+ 'color: #FA8400; font-weight: bold; font-size: 18px;',
207
+ 'color: #00ADFE; font-weight: bold; font-size: 18px;',
208
+ "color: #333; font-size: 18px; font-family: system-ui, sans-serif;"
209
+ )
210
+ }
211
+ } catch (error) {
212
+ console.error('[Runtime] Initialization failed:', error)
213
+
214
+ // Render error state
215
+ const container = document.getElementById('root')
216
+ if (container) {
217
+ const root = createRoot(container)
218
+ root.render(
219
+ <div style={{ padding: '2rem', margin: '1rem', background: '#fef2f2', borderRadius: '0.5rem', color: '#dc2626' }}>
220
+ <h2>Runtime Error</h2>
221
+ <p>{error.message}</p>
222
+ {development && <pre style={{ fontSize: '0.75rem', overflow: 'auto' }}>{error.stack}</pre>}
223
+ </div>
224
+ )
225
+ }
226
+ }
227
+ }
228
+
229
+ // Legacy alias
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
+
245
+ export default initRuntime
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Content Collector for Vite
3
+ *
4
+ * Collects site content from a pages/ directory structure:
5
+ * - site.yml: Site configuration
6
+ * - pages/: Directory of page folders
7
+ * - page.yml: Page metadata
8
+ * - *.md: Section content with YAML frontmatter
9
+ *
10
+ * Uses @uniwebcms/content-reader for markdown → ProseMirror conversion
11
+ * when available, otherwise uses a simplified parser.
12
+ */
13
+
14
+ import { readFile, readdir, stat } from 'node:fs/promises'
15
+ import { join, parse, relative } from 'node:path'
16
+ import { existsSync } from 'node:fs'
17
+ import yaml from 'js-yaml'
18
+
19
+ // Try to import content-reader, fall back to simplified parser
20
+ let markdownToProseMirror
21
+ try {
22
+ const contentReader = await import('@uniwebcms/content-reader')
23
+ markdownToProseMirror = contentReader.markdownToProseMirror
24
+ } catch {
25
+ // Simplified fallback - just wraps content as text
26
+ markdownToProseMirror = (markdown) => ({
27
+ type: 'doc',
28
+ content: [
29
+ {
30
+ type: 'paragraph',
31
+ content: [{ type: 'text', text: markdown.trim() }]
32
+ }
33
+ ]
34
+ })
35
+ }
36
+
37
+ /**
38
+ * Parse YAML string using js-yaml
39
+ */
40
+ function parseYaml(yamlString) {
41
+ try {
42
+ return yaml.load(yamlString) || {}
43
+ } catch (err) {
44
+ console.warn('[content-collector] YAML parse error:', err.message)
45
+ return {}
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read and parse a YAML file
51
+ */
52
+ async function readYamlFile(filePath) {
53
+ try {
54
+ const content = await readFile(filePath, 'utf8')
55
+ return parseYaml(content)
56
+ } catch (err) {
57
+ if (err.code === 'ENOENT') return {}
58
+ throw err
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Check if a file is a markdown file
64
+ */
65
+ function isMarkdownFile(filename) {
66
+ return filename.endsWith('.md') && !filename.startsWith('_')
67
+ }
68
+
69
+ /**
70
+ * Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
71
+ */
72
+ function parseNumericPrefix(filename) {
73
+ const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
74
+ if (match) {
75
+ return { prefix: match[1], name: match[2] || match[1] }
76
+ }
77
+ return { prefix: null, name: filename }
78
+ }
79
+
80
+ /**
81
+ * Compare filenames for sorting by numeric prefix
82
+ */
83
+ function compareFilenames(a, b) {
84
+ const { prefix: prefixA } = parseNumericPrefix(parse(a).name)
85
+ const { prefix: prefixB } = parseNumericPrefix(parse(b).name)
86
+
87
+ if (!prefixA && !prefixB) return a.localeCompare(b)
88
+ if (!prefixA) return 1
89
+ if (!prefixB) return -1
90
+
91
+ const partsA = prefixA.split('.').map(Number)
92
+ const partsB = prefixB.split('.').map(Number)
93
+
94
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
95
+ const numA = partsA[i] ?? 0
96
+ const numB = partsB[i] ?? 0
97
+ if (numA !== numB) return numA - numB
98
+ }
99
+
100
+ return 0
101
+ }
102
+
103
+ /**
104
+ * Process a markdown file into a section
105
+ */
106
+ async function processMarkdownFile(filePath, id) {
107
+ const content = await readFile(filePath, 'utf8')
108
+ let frontMatter = {}
109
+ let markdown = content
110
+
111
+ // Extract frontmatter
112
+ if (content.trim().startsWith('---')) {
113
+ const parts = content.split('---\n')
114
+ if (parts.length >= 3) {
115
+ frontMatter = parseYaml(parts[1])
116
+ markdown = parts.slice(2).join('---\n')
117
+ }
118
+ }
119
+
120
+ const { component, preset, input, props, ...params } = frontMatter
121
+
122
+ // Convert markdown to ProseMirror
123
+ const proseMirrorContent = markdownToProseMirror(markdown)
124
+
125
+ return {
126
+ id,
127
+ component: component || 'Section',
128
+ preset,
129
+ input,
130
+ params: { ...params, ...props },
131
+ content: proseMirrorContent,
132
+ subsections: []
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Build section hierarchy from flat list
138
+ */
139
+ function buildSectionHierarchy(sections) {
140
+ const sectionMap = new Map()
141
+ const topLevel = []
142
+
143
+ // First pass: create map
144
+ for (const section of sections) {
145
+ sectionMap.set(section.id, section)
146
+ }
147
+
148
+ // Second pass: build hierarchy
149
+ for (const section of sections) {
150
+ if (!section.id.includes('.')) {
151
+ topLevel.push(section)
152
+ continue
153
+ }
154
+
155
+ const parts = section.id.split('.')
156
+ const parentId = parts.slice(0, -1).join('.')
157
+ const parent = sectionMap.get(parentId)
158
+
159
+ if (parent) {
160
+ parent.subsections.push(section)
161
+ } else {
162
+ // Orphan subsection - add to top level
163
+ topLevel.push(section)
164
+ }
165
+ }
166
+
167
+ return topLevel
168
+ }
169
+
170
+ /**
171
+ * Process a page directory
172
+ */
173
+ async function processPage(pagePath, pageName, headerSections, footerSections) {
174
+ const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
175
+
176
+ if (pageConfig.hidden) return null
177
+
178
+ // Get markdown files
179
+ const files = await readdir(pagePath)
180
+ const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
181
+
182
+ // Process sections
183
+ const sections = []
184
+ for (const file of mdFiles) {
185
+ const { name } = parse(file)
186
+ const { prefix } = parseNumericPrefix(name)
187
+ const id = prefix || name
188
+
189
+ const section = await processMarkdownFile(join(pagePath, file), id)
190
+ sections.push(section)
191
+ }
192
+
193
+ // Build hierarchy
194
+ const hierarchicalSections = buildSectionHierarchy(sections)
195
+
196
+ // Determine route
197
+ let route = '/' + pageName
198
+ if (pageName === 'home' || pageName === 'index') {
199
+ route = '/'
200
+ } else if (pageName.startsWith('@')) {
201
+ route = '/' + pageName
202
+ }
203
+
204
+ return {
205
+ route,
206
+ title: pageConfig.title || pageName,
207
+ description: pageConfig.description || '',
208
+ order: pageConfig.order,
209
+ sections: hierarchicalSections
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Collect all site content
215
+ */
216
+ export async function collectSiteContent(sitePath) {
217
+ const pagesPath = join(sitePath, 'pages')
218
+
219
+ // Read site config
220
+ const siteConfig = await readYamlFile(join(sitePath, 'site.yml'))
221
+ const themeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
222
+
223
+ // Check if pages directory exists
224
+ if (!existsSync(pagesPath)) {
225
+ return {
226
+ config: siteConfig,
227
+ theme: themeConfig,
228
+ pages: []
229
+ }
230
+ }
231
+
232
+ // Get page directories
233
+ const entries = await readdir(pagesPath)
234
+ const pages = []
235
+ let header = null
236
+ let footer = null
237
+
238
+ for (const entry of entries) {
239
+ const entryPath = join(pagesPath, entry)
240
+ const stats = await stat(entryPath)
241
+
242
+ if (!stats.isDirectory()) continue
243
+
244
+ const page = await processPage(entryPath, entry)
245
+ if (!page) continue
246
+
247
+ // Handle special pages
248
+ if (entry === '@header' || page.route === '/@header') {
249
+ header = page
250
+ } else if (entry === '@footer' || page.route === '/@footer') {
251
+ footer = page
252
+ } else {
253
+ pages.push(page)
254
+ }
255
+ }
256
+
257
+ // Sort pages by order
258
+ pages.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
259
+
260
+ return {
261
+ config: siteConfig,
262
+ theme: themeConfig,
263
+ pages,
264
+ header,
265
+ footer
266
+ }
267
+ }
268
+
269
+ export default collectSiteContent