@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.
- package/LICENSE +201 -0
- package/README.md +229 -0
- package/package.json +50 -0
- package/src/components/BlockRenderer.jsx +124 -0
- package/src/components/ErrorBoundary.jsx +51 -0
- package/src/components/Link.jsx +28 -0
- package/src/components/PageRenderer.jsx +57 -0
- package/src/components/SafeHtml.jsx +22 -0
- package/src/components/WebsiteRenderer.jsx +84 -0
- package/src/core/block.js +279 -0
- package/src/core/input.js +15 -0
- package/src/core/page.js +75 -0
- package/src/core/uniweb.js +103 -0
- package/src/core/website.js +157 -0
- package/src/index.js +245 -0
- package/src/vite/content-collector.js +269 -0
- package/src/vite/foundation-plugin.js +194 -0
- package/src/vite/index.js +7 -0
- package/src/vite/site-content-plugin.js +135 -0
|
@@ -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
|