@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.1.16",
3
+ "version": "0.2.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
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
- // Start with default locale
103
- const allLocaleCodes = [defaultLocale]
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 translated locales (avoiding duplicates)
105
+ // Add i18n locales (may include objects with labels)
106
106
  for (const locale of i18nLocales) {
107
- if (!allLocaleCodes.includes(locale)) {
108
- allLocaleCodes.push(locale)
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 full locale objects
113
- return allLocaleCodes.map(code => ({
114
- code,
115
- label: LOCALE_NAMES[code] || code.toUpperCase(),
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
- * @param {string} href
451
- * @returns {string}
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
- // Could add basename handling here
455
- return href
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
- * @returns {Array<{code: string, label: string, isDefault: boolean}>}
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
  }