@uniweb/core 0.1.16 → 0.2.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/package.json +1 -1
- package/src/block.js +1 -0
- package/src/page.js +60 -0
- package/src/website.js +255 -34
package/package.json
CHANGED
package/src/block.js
CHANGED
|
@@ -10,6 +10,7 @@ import { parseContent as parseSemanticContent } from '@uniweb/semantic-parser'
|
|
|
10
10
|
export default class Block {
|
|
11
11
|
constructor(blockData, id) {
|
|
12
12
|
this.id = id
|
|
13
|
+
this.stableId = blockData.stableId || null // Stable section ID for scroll targeting (from filename or frontmatter)
|
|
13
14
|
// 'type' matches frontmatter convention; 'component' supported for backwards compatibility
|
|
14
15
|
this.type = blockData.type || blockData.component || 'Section'
|
|
15
16
|
this.Component = null
|
package/src/page.js
CHANGED
|
@@ -18,6 +18,7 @@ export default class Page {
|
|
|
18
18
|
pageRight,
|
|
19
19
|
) {
|
|
20
20
|
this.id = id
|
|
21
|
+
this.stableId = pageData.id || null // Stable page ID for page: links (from page.yml)
|
|
21
22
|
this.route = pageData.route
|
|
22
23
|
this.isIndex = pageData.isIndex || false // True if this page is the index for its parent route
|
|
23
24
|
this.title = pageData.title || ''
|
|
@@ -66,6 +67,11 @@ export default class Page {
|
|
|
66
67
|
// Dynamic route context (for pages created from dynamic routes like /blog/:slug)
|
|
67
68
|
this.dynamicContext = pageData.dynamicContext || null
|
|
68
69
|
|
|
70
|
+
// Version context (for pages within versioned sections like /docs/v1/*)
|
|
71
|
+
this.version = pageData.version || null // { id, label, latest, deprecated }
|
|
72
|
+
this.versionMeta = pageData.versionMeta || null // { versions, latestId }
|
|
73
|
+
this.versionScope = pageData.versionScope || null // The route where versioning starts
|
|
74
|
+
|
|
69
75
|
// Build block groups for all layout areas
|
|
70
76
|
this.pageBlocks = this.buildPageBlocks(
|
|
71
77
|
pageData.sections,
|
|
@@ -501,4 +507,58 @@ export default class Page {
|
|
|
501
507
|
isActiveOrAncestor(currentRoute) {
|
|
502
508
|
return this.website.isRouteActiveOrAncestor(this.route, currentRoute)
|
|
503
509
|
}
|
|
510
|
+
|
|
511
|
+
// ─────────────────────────────────────────────────────────────────
|
|
512
|
+
// Version API (for documentation pages)
|
|
513
|
+
// ─────────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Check if this page is within a versioned section
|
|
517
|
+
* @returns {boolean}
|
|
518
|
+
*/
|
|
519
|
+
isVersioned() {
|
|
520
|
+
return this.version !== null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Get the current version for this page
|
|
525
|
+
* @returns {Object|null} Version info { id, label, latest, deprecated } or null
|
|
526
|
+
*/
|
|
527
|
+
getVersion() {
|
|
528
|
+
return this.version
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Get all available versions for this page's scope
|
|
533
|
+
* @returns {Array} Array of version objects, or empty array
|
|
534
|
+
*/
|
|
535
|
+
getVersions() {
|
|
536
|
+
return this.versionMeta?.versions || []
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Check if this page is on the latest version
|
|
541
|
+
* @returns {boolean}
|
|
542
|
+
*/
|
|
543
|
+
isLatestVersion() {
|
|
544
|
+
return this.version?.latest === true
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Check if this page is on a deprecated version
|
|
549
|
+
* @returns {boolean}
|
|
550
|
+
*/
|
|
551
|
+
isDeprecatedVersion() {
|
|
552
|
+
return this.version?.deprecated === true
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Get URL for switching to a different version of this page
|
|
557
|
+
* @param {string} targetVersion - Target version ID (e.g., 'v1')
|
|
558
|
+
* @returns {string|null} Target URL or null if not versioned
|
|
559
|
+
*/
|
|
560
|
+
getVersionUrl(targetVersion) {
|
|
561
|
+
if (!this.isVersioned()) return null
|
|
562
|
+
return this.website.getVersionUrl(targetVersion, this.route)
|
|
563
|
+
}
|
|
504
564
|
}
|
package/src/website.js
CHANGED
|
@@ -6,26 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import Page from './page.js'
|
|
8
8
|
|
|
9
|
-
// Common locale display names
|
|
10
|
-
const LOCALE_NAMES = {
|
|
11
|
-
en: 'English',
|
|
12
|
-
es: 'Español',
|
|
13
|
-
fr: 'Français',
|
|
14
|
-
de: 'Deutsch',
|
|
15
|
-
it: 'Italiano',
|
|
16
|
-
pt: 'Português',
|
|
17
|
-
nl: 'Nederlands',
|
|
18
|
-
pl: 'Polski',
|
|
19
|
-
ru: 'Русский',
|
|
20
|
-
ja: '日本語',
|
|
21
|
-
ko: '한국어',
|
|
22
|
-
zh: '中文',
|
|
23
|
-
ar: 'العربية'
|
|
24
|
-
}
|
|
25
|
-
|
|
26
9
|
export default class Website {
|
|
27
10
|
constructor(websiteData) {
|
|
28
|
-
const { pages = [], theme = {}, config = {}, header, footer, left, right, notFound } = websiteData
|
|
11
|
+
const { pages = [], theme = {}, config = {}, header, footer, left, right, notFound, versionedScopes = {} } = websiteData
|
|
29
12
|
|
|
30
13
|
// Site metadata
|
|
31
14
|
this.name = config.name || ''
|
|
@@ -86,40 +69,62 @@ export default class Website {
|
|
|
86
69
|
// Legacy language support (for editor multilingual)
|
|
87
70
|
this.activeLang = this.activeLocale
|
|
88
71
|
this.langs = config.languages || this.locales.map(l => ({
|
|
89
|
-
label: l.label,
|
|
72
|
+
label: l.label || l.code,
|
|
90
73
|
value: l.code
|
|
91
74
|
}))
|
|
75
|
+
|
|
76
|
+
// Versioned scopes: route → { versions, latestId }
|
|
77
|
+
// Scopes are routes where versioning starts (e.g., '/docs')
|
|
78
|
+
this.versionedScopes = versionedScopes
|
|
92
79
|
}
|
|
93
80
|
|
|
94
81
|
/**
|
|
95
82
|
* Build locales list from config
|
|
83
|
+
* Supports both string codes and objects: ['es', 'fr'] or [{code: 'es', label: 'Español'}]
|
|
84
|
+
* Labels are passed through if provided; otherwise only code is returned.
|
|
85
|
+
* Use kit's getLocaleLabel() for display names.
|
|
96
86
|
* @private
|
|
97
87
|
*/
|
|
98
88
|
buildLocalesList(config) {
|
|
99
89
|
const defaultLocale = config.defaultLanguage || 'en'
|
|
100
90
|
const i18nLocales = config.i18n?.locales || []
|
|
101
91
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
92
|
+
// Normalize input: convert strings to objects, keep objects as-is
|
|
93
|
+
const normalizeLocale = (locale) => {
|
|
94
|
+
if (typeof locale === 'string') {
|
|
95
|
+
return { code: locale }
|
|
96
|
+
}
|
|
97
|
+
// Object with code and optional label
|
|
98
|
+
return { code: locale.code, ...(locale.label && { label: locale.label }) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Start with default locale (may not be in i18nLocales)
|
|
102
|
+
const localeMap = new Map()
|
|
103
|
+
localeMap.set(defaultLocale, { code: defaultLocale })
|
|
104
104
|
|
|
105
|
-
// Add
|
|
105
|
+
// Add i18n locales (may include objects with labels)
|
|
106
106
|
for (const locale of i18nLocales) {
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
const normalized = normalizeLocale(locale)
|
|
108
|
+
// Merge with existing (to preserve labels if default locale also in i18n with label)
|
|
109
|
+
if (localeMap.has(normalized.code)) {
|
|
110
|
+
const existing = localeMap.get(normalized.code)
|
|
111
|
+
localeMap.set(normalized.code, { ...existing, ...normalized })
|
|
112
|
+
} else {
|
|
113
|
+
localeMap.set(normalized.code, normalized)
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
// Build
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
isDefault: code === defaultLocale
|
|
117
|
+
// Build final array with isDefault flag
|
|
118
|
+
return Array.from(localeMap.values()).map(locale => ({
|
|
119
|
+
...locale,
|
|
120
|
+
isDefault: locale.code === defaultLocale
|
|
117
121
|
}))
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
/**
|
|
121
125
|
* Build parent-child relationships between pages based on route structure
|
|
122
126
|
* E.g., /getting-started/installation is a child of /getting-started
|
|
127
|
+
* Also builds page ID map for makeHref() resolution
|
|
123
128
|
* @private
|
|
124
129
|
*/
|
|
125
130
|
buildPageHierarchy() {
|
|
@@ -168,6 +173,21 @@ export default class Website {
|
|
|
168
173
|
page.children.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
169
174
|
}
|
|
170
175
|
}
|
|
176
|
+
|
|
177
|
+
// Build page ID map for makeHref() resolution
|
|
178
|
+
// Supports both explicit IDs and route-based lookup
|
|
179
|
+
this._pageIdMap = new Map()
|
|
180
|
+
for (const page of this.pages) {
|
|
181
|
+
// Explicit stableId takes priority (survives page reorganization)
|
|
182
|
+
if (page.stableId) {
|
|
183
|
+
this._pageIdMap.set(page.stableId, page)
|
|
184
|
+
}
|
|
185
|
+
// Route-based lookup (normalized, without leading/trailing slashes)
|
|
186
|
+
const routeId = this.normalizeRoute(page.route)
|
|
187
|
+
if (routeId && !this._pageIdMap.has(routeId)) {
|
|
188
|
+
this._pageIdMap.set(routeId, page)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
/**
|
|
@@ -447,12 +467,46 @@ export default class Website {
|
|
|
447
467
|
|
|
448
468
|
/**
|
|
449
469
|
* Make href (for link transformation)
|
|
450
|
-
*
|
|
451
|
-
*
|
|
470
|
+
* Resolves page: references to actual routes
|
|
471
|
+
*
|
|
472
|
+
* @param {string} href - The href to transform
|
|
473
|
+
* @returns {string} Resolved href
|
|
474
|
+
*
|
|
475
|
+
* @example
|
|
476
|
+
* makeHref('page:getting-started') // → '/docs/getting-started'
|
|
477
|
+
* makeHref('page:getting-started#install') // → '/docs/getting-started#section-install'
|
|
478
|
+
* makeHref('page:docs/api') // → '/docs/api' (route-based)
|
|
479
|
+
* makeHref('/about') // → '/about' (passthrough)
|
|
452
480
|
*/
|
|
453
481
|
makeHref(href) {
|
|
454
|
-
|
|
455
|
-
|
|
482
|
+
if (!href || !href.startsWith('page:')) {
|
|
483
|
+
return href
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Parse page reference: page:pageId#sectionId
|
|
487
|
+
const withoutPrefix = href.slice(5) // Remove 'page:'
|
|
488
|
+
const [pageId, sectionId] = withoutPrefix.split('#')
|
|
489
|
+
|
|
490
|
+
// Look up page by ID (explicit or route-based)
|
|
491
|
+
const page = this._pageIdMap?.get(pageId)
|
|
492
|
+
|
|
493
|
+
if (!page) {
|
|
494
|
+
// Page not found - return original href (or could warn in dev)
|
|
495
|
+
if (typeof console !== 'undefined' && process?.env?.NODE_ENV !== 'production') {
|
|
496
|
+
console.warn(`[makeHref] Page not found: ${pageId}`)
|
|
497
|
+
}
|
|
498
|
+
return href
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Build the resolved href
|
|
502
|
+
let resolvedHref = page.route
|
|
503
|
+
|
|
504
|
+
// Add section hash if specified (with section- prefix for DOM ID)
|
|
505
|
+
if (sectionId) {
|
|
506
|
+
resolvedHref += `#section-${sectionId}`
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return resolvedHref
|
|
456
510
|
}
|
|
457
511
|
|
|
458
512
|
/**
|
|
@@ -477,7 +531,8 @@ export default class Website {
|
|
|
477
531
|
|
|
478
532
|
/**
|
|
479
533
|
* Get all available locales
|
|
480
|
-
*
|
|
534
|
+
* Label is optional - use kit's getLocaleLabel() for display names if not provided.
|
|
535
|
+
* @returns {Array<{code: string, label?: string, isDefault: boolean}>}
|
|
481
536
|
*/
|
|
482
537
|
getLocales() {
|
|
483
538
|
return this.locales
|
|
@@ -871,4 +926,170 @@ export default class Website {
|
|
|
871
926
|
// Check if current starts with target followed by /
|
|
872
927
|
return current.startsWith(target + '/')
|
|
873
928
|
}
|
|
929
|
+
|
|
930
|
+
// ─────────────────────────────────────────────────────────────────
|
|
931
|
+
// Version API (for documentation sites)
|
|
932
|
+
// ─────────────────────────────────────────────────────────────────
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Get all versioned scopes
|
|
936
|
+
* Returns a map of scope routes to their version metadata
|
|
937
|
+
*
|
|
938
|
+
* @returns {Object} Map of scope → { versions, latestId }
|
|
939
|
+
*
|
|
940
|
+
* @example
|
|
941
|
+
* website.getVersionedScopes()
|
|
942
|
+
* // { '/docs': { versions: [...], latestId: 'v2' } }
|
|
943
|
+
*/
|
|
944
|
+
getVersionedScopes() {
|
|
945
|
+
return this.versionedScopes
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Check if site has any versioned content
|
|
950
|
+
* @returns {boolean}
|
|
951
|
+
*/
|
|
952
|
+
hasVersionedContent() {
|
|
953
|
+
return Object.keys(this.versionedScopes).length > 0
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Get the versioned scope that contains a given route
|
|
958
|
+
* Returns the scope route if the route is within a versioned section
|
|
959
|
+
*
|
|
960
|
+
* @param {string} route - Route to check (e.g., '/docs/getting-started')
|
|
961
|
+
* @returns {string|null} The scope route (e.g., '/docs') or null
|
|
962
|
+
*
|
|
963
|
+
* @example
|
|
964
|
+
* website.getVersionScope('/docs/getting-started') // '/docs'
|
|
965
|
+
* website.getVersionScope('/about') // null
|
|
966
|
+
*/
|
|
967
|
+
getVersionScope(route) {
|
|
968
|
+
const normalizedRoute = route || ''
|
|
969
|
+
|
|
970
|
+
// Check each versioned scope to see if route falls within it
|
|
971
|
+
for (const scope of Object.keys(this.versionedScopes)) {
|
|
972
|
+
// Route matches scope exactly or is a child of scope
|
|
973
|
+
if (normalizedRoute === scope || normalizedRoute.startsWith(scope + '/')) {
|
|
974
|
+
return scope
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return null
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Check if a route is within a versioned section
|
|
983
|
+
*
|
|
984
|
+
* @param {string} route - Route to check
|
|
985
|
+
* @returns {boolean}
|
|
986
|
+
*/
|
|
987
|
+
isVersionedRoute(route) {
|
|
988
|
+
return this.getVersionScope(route) !== null
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Get version metadata for a scope
|
|
993
|
+
*
|
|
994
|
+
* @param {string} scope - The scope route (e.g., '/docs')
|
|
995
|
+
* @returns {Object|null} Version metadata { versions, latestId } or null
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* website.getVersionMeta('/docs')
|
|
999
|
+
* // { versions: [{ id: 'v2', label: 'v2', latest: true }, ...], latestId: 'v2' }
|
|
1000
|
+
*/
|
|
1001
|
+
getVersionMeta(scope) {
|
|
1002
|
+
return this.versionedScopes[scope] || null
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Get the current version for a page
|
|
1007
|
+
* Returns the version object from the page's version metadata
|
|
1008
|
+
*
|
|
1009
|
+
* @param {Page} page - The page to check
|
|
1010
|
+
* @returns {Object|null} Version object { id, label, latest, deprecated } or null
|
|
1011
|
+
*/
|
|
1012
|
+
getPageVersion(page) {
|
|
1013
|
+
return page?.version || null
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Get available versions for a route's scope
|
|
1018
|
+
*
|
|
1019
|
+
* @param {string} route - Route within a versioned scope
|
|
1020
|
+
* @returns {Array} Array of version objects, or empty array
|
|
1021
|
+
*
|
|
1022
|
+
* @example
|
|
1023
|
+
* website.getVersionsForRoute('/docs/getting-started')
|
|
1024
|
+
* // [{ id: 'v2', label: 'v2', latest: true }, { id: 'v1', label: 'v1' }]
|
|
1025
|
+
*/
|
|
1026
|
+
getVersionsForRoute(route) {
|
|
1027
|
+
const scope = this.getVersionScope(route)
|
|
1028
|
+
if (!scope) return []
|
|
1029
|
+
|
|
1030
|
+
const meta = this.versionedScopes[scope]
|
|
1031
|
+
return meta?.versions || []
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Compute URL for switching to a different version
|
|
1036
|
+
* Takes the current route and computes what the URL would be for another version
|
|
1037
|
+
*
|
|
1038
|
+
* @param {string} targetVersion - Target version ID (e.g., 'v1')
|
|
1039
|
+
* @param {string} currentRoute - Current route (e.g., '/docs/getting-started')
|
|
1040
|
+
* @returns {string|null} Target URL or null if not versioned
|
|
1041
|
+
*
|
|
1042
|
+
* @example
|
|
1043
|
+
* // Current: /docs/getting-started (latest v2)
|
|
1044
|
+
* website.getVersionUrl('v1', '/docs/getting-started')
|
|
1045
|
+
* // → '/docs/v1/getting-started'
|
|
1046
|
+
*
|
|
1047
|
+
* // Current: /docs/v1/getting-started (older v1)
|
|
1048
|
+
* website.getVersionUrl('v2', '/docs/v1/getting-started')
|
|
1049
|
+
* // → '/docs/getting-started' (latest has no prefix)
|
|
1050
|
+
*/
|
|
1051
|
+
getVersionUrl(targetVersion, currentRoute) {
|
|
1052
|
+
const scope = this.getVersionScope(currentRoute)
|
|
1053
|
+
if (!scope) return null
|
|
1054
|
+
|
|
1055
|
+
const meta = this.versionedScopes[scope]
|
|
1056
|
+
if (!meta) return null
|
|
1057
|
+
|
|
1058
|
+
// Find target version info
|
|
1059
|
+
const targetVersionInfo = meta.versions.find(v => v.id === targetVersion)
|
|
1060
|
+
if (!targetVersionInfo) return null
|
|
1061
|
+
|
|
1062
|
+
// Extract the path within the scope (after scope and any version prefix)
|
|
1063
|
+
const afterScope = currentRoute.slice(scope.length) // e.g., '/getting-started' or '/v1/getting-started'
|
|
1064
|
+
|
|
1065
|
+
// Check if current route has a version prefix
|
|
1066
|
+
let pathWithinVersion = afterScope
|
|
1067
|
+
for (const version of meta.versions) {
|
|
1068
|
+
const versionPrefix = `/${version.id}`
|
|
1069
|
+
if (afterScope.startsWith(versionPrefix + '/') || afterScope === versionPrefix) {
|
|
1070
|
+
// Remove version prefix
|
|
1071
|
+
pathWithinVersion = afterScope.slice(versionPrefix.length)
|
|
1072
|
+
break
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Build target URL
|
|
1077
|
+
// Latest version has no prefix, others have /vN prefix
|
|
1078
|
+
if (targetVersionInfo.latest) {
|
|
1079
|
+
return scope + pathWithinVersion
|
|
1080
|
+
} else {
|
|
1081
|
+
return scope + '/' + targetVersion + pathWithinVersion
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Get the latest version ID for a scope
|
|
1087
|
+
*
|
|
1088
|
+
* @param {string} scope - The scope route
|
|
1089
|
+
* @returns {string|null} Latest version ID or null
|
|
1090
|
+
*/
|
|
1091
|
+
getLatestVersion(scope) {
|
|
1092
|
+
const meta = this.versionedScopes[scope]
|
|
1093
|
+
return meta?.latestId || null
|
|
1094
|
+
}
|
|
874
1095
|
}
|