@uniweb/core 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/uniweb.js CHANGED
@@ -1,43 +1,92 @@
1
1
  /**
2
2
  * Uniweb Core Runtime
3
3
  *
4
- * The main runtime instance that manages the website, foundation components,
5
- * and provides utilities to components.
4
+ * Singleton that holds the Website, routing components, icon resolver, and
5
+ * foundation declaration references. Kit hooks, the icon resolver, and the
6
+ * prepare-props pipeline read from here via `globalThis.uniweb`.
7
+ *
8
+ * The foundation and extensions are passed at construction time — the Website
9
+ * constructs its FetcherDispatcher from them, and the Uniweb singleton caches
10
+ * the same references so the kit can still do `globalThis.uniweb.getComponent(name)`
11
+ * and similar lookups without touching the Website.
6
12
  */
7
13
 
8
14
  import Website from './website.js'
9
15
  import Analytics from './analytics.js'
10
16
 
11
17
  export default class Uniweb {
12
- constructor(configData) {
13
- this.activeWebsite = new Website(configData)
14
- this.childBlockRenderer = null // Function to render child blocks
15
- this.routingComponents = {} // Link, SafeHtml, useNavigate, etc.
16
- this.foundation = null // The loaded foundation module
17
- this.foundationConfig = {} // Configuration from foundation (capabilities)
18
- this.meta = {} // Per-component runtime metadata (from meta.js)
19
- this.extensions = [] // Array of { foundation, meta } objects
18
+ /**
19
+ * @param {Object} options
20
+ * @param {Object} options.content - Site content payload (pages, theme, config, layouts, ...).
21
+ * @param {Object|null} [options.foundation] - Loaded primary foundation module.
22
+ * @param {Array<Object>} [options.extensions] - Loaded extension modules.
23
+ * @param {{ resolve: Function }} [options.defaultFetcher] - Framework default fetcher
24
+ * used by the dispatcher's fallback when no foundation route matches.
25
+ * @param {{ resolve: Function, cacheKey?: Function }} [options.transport] -
26
+ * Runtime-level transport override. When set, every Layer-1 request is
27
+ * routed through this transport, bypassing foundation routes and the
28
+ * framework default. Used only by the editor's preview iframe; normal
29
+ * sites never pass this option.
30
+ */
31
+ constructor({ content = {}, foundation = null, extensions = [], defaultFetcher = null, transport = null, dev = false } = {}) {
32
+ this.activeWebsite = new Website({ content, foundation, extensions, defaultFetcher, transport, dev })
33
+
34
+ this.foundation = foundation
35
+ this.foundationConfig = {}
36
+ this.meta = foundation?.default?.meta || {}
37
+ this.extensions = []
38
+
39
+ if (foundation?.default?.capabilities) {
40
+ this.foundationConfig = { ...foundation.default.capabilities }
41
+ }
42
+ if (foundation?.default?.layoutMeta) {
43
+ this.foundationConfig.layoutMeta = foundation.default.layoutMeta
44
+ }
45
+ if (foundation?.default?.handlers) {
46
+ this.foundationConfig.handlers = foundation.default.handlers
47
+ }
48
+ if (foundation?.default?.viewTransitions !== undefined) {
49
+ this.foundationConfig.viewTransitions = foundation.default.viewTransitions
50
+ }
51
+
52
+ for (const ext of extensions) {
53
+ this._wireExtension(ext)
54
+ }
55
+
56
+ this.childBlockRenderer = null
57
+ this.routingComponents = {}
20
58
  this.language = 'en'
21
59
 
22
60
  // Icon resolver: (library, name) => Promise<string|null>
23
- // Set by runtime based on site config
61
+ // Set by the runtime from site config.
24
62
  this.iconResolver = null
25
63
 
26
64
  // Pre-populated icon cache for SSR: Map<"family:name", svgString>
27
- // Populated by prerender before rendering, read synchronously by Icon component
65
+ // Populated by prerender before rendering, read synchronously by Icon.
28
66
  this.iconCache = new Map()
29
67
 
30
- // Initialize analytics (disabled by default, configure via site config)
31
- this.analytics = new Analytics(configData.analytics || {})
68
+ this.analytics = new Analytics(content?.analytics || content?.config?.analytics || {})
32
69
 
33
70
  Object.seal(this)
34
71
  }
35
72
 
36
73
  /**
37
- * Resolve an icon by library and name
38
- * @param {string} library - Icon family (lucide, heroicons, etc.)
39
- * @param {string} name - Icon name (check, arrow-right, etc.)
40
- * @returns {Promise<string|null>} SVG string or null
74
+ * Wire an extension into the singleton. Kit's getComponent falls through
75
+ * from the primary foundation to each extension in declared order; meta
76
+ * lookups do the same.
77
+ *
78
+ * @private
79
+ */
80
+ _wireExtension(foundation) {
81
+ const meta = foundation?.default?.meta || {}
82
+ this.extensions.push({ foundation, meta })
83
+ }
84
+
85
+ /**
86
+ * Resolve an icon by library and name.
87
+ * @param {string} library
88
+ * @param {string} name
89
+ * @returns {Promise<string|null>}
41
90
  */
42
91
  async resolveIcon(library, name) {
43
92
  if (!this.iconResolver) {
@@ -48,114 +97,56 @@ export default class Uniweb {
48
97
  }
49
98
 
50
99
  /**
51
- * Get a cached icon synchronously (for SSR/prerender)
52
- * @param {string} library - Icon family code
53
- * @param {string} name - Icon name
54
- * @returns {string|null} SVG string or null if not cached
100
+ * Synchronous icon lookup for SSR.
55
101
  */
56
102
  getIconSync(library, name) {
57
103
  return this.iconCache.get(`${library}:${name}`) || null
58
104
  }
59
105
 
60
106
  /**
61
- * Set the foundation module after loading
62
- * @param {Object} foundation - The loaded ESM foundation module
63
- */
64
- setFoundation(foundation) {
65
- this.foundation = foundation
66
-
67
- // Store per-component metadata if present (lives under default export)
68
- if (foundation.default?.meta) {
69
- this.meta = foundation.default.meta
70
- }
71
- }
72
-
73
- /**
74
- * Register an extension (secondary foundation)
75
- * @param {Object} foundation - The loaded ESM extension module
76
- */
77
- registerExtension(foundation) {
78
- const meta = foundation.default?.meta || {}
79
- this.extensions.push({ foundation, meta })
80
- }
81
-
82
- /**
83
- * Get runtime metadata for a component
84
- * @param {string} componentName
85
- * @returns {Object|null} Meta with defaults, context, initialState, background, data
107
+ * Get per-component runtime metadata primary first, then extensions in
108
+ * declared order.
86
109
  */
87
110
  getComponentMeta(componentName) {
88
111
  const primary = this.meta[componentName]
89
112
  if (primary) return primary
90
-
91
113
  for (const ext of this.extensions) {
92
114
  const meta = ext.meta[componentName]
93
115
  if (meta) return meta
94
116
  }
95
-
96
117
  return null
97
118
  }
98
119
 
99
- /**
100
- * Get default param values for a component
101
- * @param {string} componentName
102
- * @returns {Object} Default values (empty object if none)
103
- */
104
120
  getComponentDefaults(componentName) {
105
121
  return this.getComponentMeta(componentName)?.defaults || {}
106
122
  }
107
123
 
108
- /**
109
- * Get a component from the foundation by name
110
- * @param {string} name - Component name
111
- * @returns {React.ComponentType|undefined}
112
- */
113
124
  getComponent(name) {
114
125
  if (!this.foundation) {
115
126
  console.warn('[Runtime] No foundation loaded')
116
127
  return undefined
117
128
  }
118
-
119
- // Primary foundation first (components are named exports)
120
129
  const primary = this.foundation[name]
121
130
  if (primary) return primary
122
-
123
- // Fall through to extensions (declared order)
124
131
  for (const ext of this.extensions) {
125
132
  const component = ext.foundation[name]
126
133
  if (component) return component
127
134
  }
128
-
129
135
  return undefined
130
136
  }
131
137
 
132
- /**
133
- * List available components from the foundation
134
- * @returns {string[]}
135
- */
136
138
  listComponents() {
137
139
  const names = new Set()
138
-
139
140
  if (this.foundation) {
140
141
  for (const name of Object.keys(this.foundation)) {
141
142
  if (name !== 'default') names.add(name)
142
143
  }
143
144
  }
144
-
145
145
  for (const ext of this.extensions) {
146
146
  for (const name of Object.keys(ext.foundation)) {
147
147
  if (name !== 'default') names.add(name)
148
148
  }
149
149
  }
150
-
151
150
  return [...names]
152
151
  }
153
-
154
- /**
155
- * Set foundation configuration
156
- * @param {Object} config
157
- */
158
- setFoundationConfig(config) {
159
- this.foundationConfig = config
160
- }
161
152
  }
package/src/website.js CHANGED
@@ -7,18 +7,111 @@
7
7
  import Page from './page.js'
8
8
  import DataStore from './datastore.js'
9
9
  import EntityStore from './entity-store.js'
10
+ import FetcherDispatcher from './fetcher-dispatcher.js'
11
+ import ObservableState from './observable-state.js'
10
12
  import singularize from './singularize.js'
11
13
 
14
+ /**
15
+ * Website — orchestration root for a single site instance.
16
+ *
17
+ * Accepts the site content payload plus the primary foundation and any
18
+ * extensions. Owns the DataStore (pure cache), EntityStore (cascade resolver),
19
+ * FetcherDispatcher (route walker + cache+in-flight wiring), and `state`
20
+ * (site-wide observable slots). Pages are constructed from the content payload;
21
+ * each page owns its own ObservableState.
22
+ *
23
+ * Content-only rebuilds keep the dispatcher and state in place. Foundation
24
+ * swaps reassemble the dispatcher but preserve the DataStore and state so the
25
+ * editor's live-edit path doesn't wipe either between keystrokes.
26
+ *
27
+ * new Website({ content, foundation?, extensions?, defaultFetcher?, dev? })
28
+ */
12
29
  export default class Website {
13
- constructor(websiteData) {
14
- const { pages = [], theme = {}, config = {}, layouts, notFound, versionedScopes = {} } = websiteData
30
+ constructor({
31
+ content = {},
32
+ foundation = null,
33
+ extensions = [],
34
+ defaultFetcher = null,
35
+ transport = null,
36
+ dev = false,
37
+ } = {}) {
38
+
39
+ // ─── Foundation / dispatcher state (not re-derived on rebuild) ───
40
+ this._foundation = foundation
41
+ this._extensions = extensions
42
+ this._defaultFetcher = defaultFetcher
43
+ // Runtime-level transport override (editor preview bridge). Stored so
44
+ // rebuild() reassembles the dispatcher with the same override in place.
45
+ this._transport = transport
46
+ this._dev = dev
47
+
48
+ this.dataStore = new DataStore()
49
+ this.fetcher = new FetcherDispatcher({
50
+ foundation,
51
+ extensions,
52
+ dataStore: this.dataStore,
53
+ defaultFetcher,
54
+ transport,
55
+ dev,
56
+ })
57
+ this.entityStore = new EntityStore({ website: this })
58
+
59
+ // Observable site-wide state — allocated on first access via the `state`
60
+ // getter, survives content rebuilds. Read-only prop (no `website.state = X`
61
+ // reassignment) so callers can only mutate slots via website.state.set(...).
62
+ this._state = null
63
+
64
+ // ─── Fields populated by _applyContent (declared up front so Object.seal works) ───
65
+ this.name = ''
66
+ this.description = ''
67
+ this.url = ''
68
+ this._layoutSets = {}
69
+ this.notFoundPage = null
70
+ this._dynamicPageData = new Map()
71
+ this._dynamicPageCache = new Map()
72
+ this.pages = []
73
+ this.activePage = null
74
+ this.pageRoutes = []
75
+ this.themeData = {}
76
+ this.config = {}
77
+ this.siteDefaultLocale = 'en'
78
+ this.defaultLocale = 'en'
79
+ this.activeLocale = 'en'
80
+ this.locales = []
81
+ this.activeLang = 'en'
82
+ this.langs = []
83
+ this._routeTranslations = {}
84
+ this.basePath = ''
85
+ this.versionedScopes = {}
86
+ this._pageIdMap = new Map()
87
+
88
+ this._applyContent(content)
89
+
90
+ Object.seal(this)
91
+ }
92
+
93
+ /**
94
+ * Populate content-derived fields from a site-content payload. Called once
95
+ * from the constructor and again from `rebuild({ content })`. All state that
96
+ * belongs on the Website but derives from the content payload lives here.
97
+ *
98
+ * @private
99
+ */
100
+ _applyContent(content) {
101
+ const {
102
+ pages = [],
103
+ theme = {},
104
+ config = {},
105
+ layouts,
106
+ notFound,
107
+ versionedScopes = {},
108
+ } = content || {}
15
109
 
16
- // Site metadata
17
110
  this.name = config.name || ''
18
111
  this.description = config.description || ''
19
112
  this.url = config.url || ''
20
113
 
21
- // General area storage: { layoutName: { areaName: Page } }
114
+ // Layout areas (header/footer/left/right pages scoped per named layout).
22
115
  this._layoutSets = {}
23
116
  if (layouts && typeof layouts === 'object') {
24
117
  for (const [name, areaData] of Object.entries(layouts)) {
@@ -31,77 +124,90 @@ export default class Website {
31
124
  }
32
125
  }
33
126
 
34
- // Store 404 page (for SPA routing)
35
- // Convention: pages/404/ directory
127
+ // 404 / not-found page (content payload or /404 route).
36
128
  const notFoundData = notFound || pages.find((p) => p.route === '/404') || null
37
129
  this.notFoundPage = notFoundData ? new Page(notFoundData, 'notFound', this) : null
38
130
 
39
- // Filter out 404 from regular pages array
40
131
  const regularPages = pages.filter((page) => page.route !== '/404')
41
132
 
42
- // Store original page data for dynamic pages (needed to create instances on-demand)
133
+ // Dynamic route templates retained in original form so the Website can
134
+ // materialize concrete pages on demand (/blog/:slug → /blog/my-post).
43
135
  this._dynamicPageData = new Map()
44
136
  for (const pageData of regularPages) {
45
137
  if (pageData.isDynamic || pageData.route?.includes(':')) {
46
138
  this._dynamicPageData.set(pageData.route, pageData)
47
139
  }
48
140
  }
49
-
50
- // Cache for dynamically created page instances
51
141
  this._dynamicPageCache = new Map()
52
142
 
53
-
54
- this.pages = regularPages.map(
55
- (page, index) => new Page(page, index, this)
56
- )
57
-
58
- // Build parent-child relationships based on route structure
143
+ this.pages = regularPages.map((page, index) => new Page(page, index, this))
59
144
  this.buildPageHierarchy()
60
145
 
61
- // Find the homepage (root-level index page)
62
146
  this.activePage =
63
147
  this.pages.find((page) => page.isIndex && page.getNavRoute() === '/') ||
64
- this.pages[0]
148
+ this.pages[0] ||
149
+ null
65
150
 
66
151
  this.pageRoutes = this.pages.map((page) => page.route)
67
152
  this.themeData = theme
68
153
  this.config = config
69
154
 
70
- // Locale configuration
71
- // siteDefaultLocale: the site's true default language (for route translations)
72
- // defaultLocale: effective default for URL prefix logic — domainLocale overrides
73
- // to prevent unnecessary /{locale}/ prefixes on domain-locale pages
74
155
  this.siteDefaultLocale = config.defaultLanguage || 'en'
75
156
  this.defaultLocale = config.domainLocale || this.siteDefaultLocale
76
157
  this.activeLocale = config.activeLocale || this.defaultLocale
77
158
 
78
- // Build locales list from i18n config
79
159
  this.locales = this.buildLocalesList(config)
80
-
81
- // Legacy language support (for editor multilingual)
82
160
  this.activeLang = this.activeLocale
83
- this.langs = this.locales.map(l => ({
84
- label: l.label || l.code,
85
- value: l.code
86
- }))
161
+ this.langs = this.locales.map((l) => ({ label: l.label || l.code, value: l.code }))
87
162
 
88
- // Route translations: locale → { forward, reverse } maps
89
163
  this._routeTranslations = this._buildRouteTranslations(config)
164
+ this.versionedScopes = versionedScopes
165
+ }
90
166
 
91
- // Deployment base path (set by runtime via setBasePath())
92
- this.basePath = ''
93
-
94
- // Runtime data cache (fetcher registered by runtime at startup)
95
- this.dataStore = new DataStore()
96
-
97
- // Entity-aware query resolution (uses DataStore for caching)
98
- this.entityStore = new EntityStore({ dataStore: this.dataStore })
167
+ /**
168
+ * Rebuild in place. Content-only rebuilds preserve the dispatcher and all
169
+ * state (site and per-page). Passing `foundation` or `extensions` reassembles
170
+ * the dispatcher; the DataStore cache survives so warm entries aren't lost.
171
+ *
172
+ * The returned value is `this` for chaining.
173
+ *
174
+ * @param {Object} options
175
+ * @param {Object} [options.content] - New site-content payload.
176
+ * @param {Object} [options.foundation] - New primary foundation module.
177
+ * @param {Array} [options.extensions] - New extensions array.
178
+ * @returns {Website}
179
+ */
180
+ rebuild({ content, foundation, extensions } = {}) {
181
+ const foundationChanged = foundation !== undefined
182
+ const extensionsChanged = extensions !== undefined
183
+ if (foundationChanged) this._foundation = foundation
184
+ if (extensionsChanged) this._extensions = extensions
185
+
186
+ if (foundationChanged || extensionsChanged) {
187
+ this.fetcher = new FetcherDispatcher({
188
+ foundation: this._foundation,
189
+ extensions: this._extensions,
190
+ dataStore: this.dataStore,
191
+ defaultFetcher: this._defaultFetcher,
192
+ transport: this._transport,
193
+ dev: this._dev,
194
+ })
195
+ }
99
196
 
100
- // Versioned scopes: route → { versions, latestId }
101
- // Scopes are routes where versioning starts (e.g., '/docs')
102
- this.versionedScopes = versionedScopes
197
+ if (content !== undefined) this._applyContent(content)
198
+ return this
199
+ }
103
200
 
104
- Object.seal(this)
201
+ /**
202
+ * Observable site-wide state. Foundations write cross-page values here
203
+ * (authenticated user, appearance preference, a filter set on /search
204
+ * that other pages honor); fetchers read it via ctx.website.state when
205
+ * handling site-level fetch configs. Lazily allocated on first read —
206
+ * sites that never touch state never build one.
207
+ */
208
+ get state() {
209
+ if (!this._state) this._state = new ObservableState()
210
+ return this._state
105
211
  }
106
212
 
107
213
  /**
@@ -452,16 +558,19 @@ export default class Website {
452
558
  const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
453
559
 
454
560
  if (parentPage && pluralSchema) {
455
- // Find collection data from parent's fetch config via DataStore
561
+ // Find collection data from parent's fetch config via the dispatcher's
562
+ // peek (sync cache probe). Used to populate the page title / notFound
563
+ // flag on dynamic pages before the page instance is constructed.
456
564
  const parentFetch = parentPage.fetch
457
565
  let items = []
458
566
 
459
- if (parentFetch) {
567
+ if (parentFetch && this.fetcher) {
460
568
  const fetchConfig = Array.isArray(parentFetch)
461
569
  ? parentFetch.find(f => f.schema === pluralSchema)
462
570
  : (parentFetch.schema === pluralSchema ? parentFetch : null)
463
571
  if (fetchConfig) {
464
- items = this.dataStore.get(fetchConfig) || []
572
+ const cached = this.fetcher.peek(fetchConfig, { website: this })
573
+ items = Array.isArray(cached?.data) ? cached.data : []
465
574
  }
466
575
  }
467
576
 
@@ -664,7 +773,7 @@ export default class Website {
664
773
 
665
774
  if (!page) {
666
775
  // Page not found - return original href (or could warn in dev)
667
- if (typeof console !== 'undefined' && process?.env?.NODE_ENV !== 'production') {
776
+ if (typeof console !== 'undefined' && typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production') {
668
777
  console.warn(`[makeHref] Page not found: ${pageId}`)
669
778
  }
670
779
  return href