@uniweb/core 0.3.7 → 0.3.8

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/website.js +125 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/website.js CHANGED
@@ -73,6 +73,9 @@ export default class Website {
73
73
  value: l.code
74
74
  }))
75
75
 
76
+ // Route translations: locale → { forward, reverse } maps
77
+ this._routeTranslations = this._buildRouteTranslations(config)
78
+
76
79
  // Versioned scopes: route → { versions, latestId }
77
80
  // Scopes are routes where versioning starts (e.g., '/docs')
78
81
  this.versionedScopes = versionedScopes
@@ -121,6 +124,73 @@ export default class Website {
121
124
  }))
122
125
  }
123
126
 
127
+ /**
128
+ * Build forward and reverse route translation maps per locale
129
+ * @private
130
+ */
131
+ _buildRouteTranslations(config) {
132
+ const translations = config.i18n?.routeTranslations || {}
133
+ const result = {}
134
+ for (const [locale, routes] of Object.entries(translations)) {
135
+ const forward = new Map() // canonical → translated
136
+ const reverse = new Map() // translated → canonical
137
+ for (const [canonical, translated] of Object.entries(routes)) {
138
+ forward.set(canonical, translated)
139
+ reverse.set(translated, canonical)
140
+ }
141
+ result[locale] = { forward, reverse }
142
+ }
143
+ return result
144
+ }
145
+
146
+ /**
147
+ * Translate a canonical route to a locale-specific display route
148
+ * Supports exact match and prefix match (e.g., /blog → /noticias also applies to /blog/my-post)
149
+ *
150
+ * @param {string} canonicalRoute - Internal route (e.g., '/about')
151
+ * @param {string} [locale] - Target locale (defaults to active locale)
152
+ * @returns {string} Translated route or original if no translation exists
153
+ */
154
+ translateRoute(canonicalRoute, locale = this.activeLocale) {
155
+ if (!locale || locale === this.defaultLocale) return canonicalRoute
156
+ const entry = this._routeTranslations[locale]
157
+ if (!entry) return canonicalRoute
158
+ // Exact match
159
+ const translated = entry.forward.get(canonicalRoute)
160
+ if (translated) return translated
161
+ // Prefix match (e.g., /blog matches /blog/my-post → /noticias/my-post)
162
+ for (const [canonical, trans] of entry.forward) {
163
+ if (canonicalRoute.startsWith(canonical + '/')) {
164
+ return trans + canonicalRoute.slice(canonical.length)
165
+ }
166
+ }
167
+ return canonicalRoute
168
+ }
169
+
170
+ /**
171
+ * Reverse-translate a display route back to the canonical route
172
+ * Used when resolving incoming URLs to find the matching page
173
+ *
174
+ * @param {string} displayRoute - Display route (e.g., '/acerca-de')
175
+ * @param {string} [locale] - Source locale (defaults to active locale)
176
+ * @returns {string} Canonical route or original if no translation exists
177
+ */
178
+ reverseTranslateRoute(displayRoute, locale = this.activeLocale) {
179
+ if (!locale || locale === this.defaultLocale) return displayRoute
180
+ const entry = this._routeTranslations[locale]
181
+ if (!entry) return displayRoute
182
+ // Exact match
183
+ const canonical = entry.reverse.get(displayRoute)
184
+ if (canonical) return canonical
185
+ // Prefix match
186
+ for (const [trans, canon] of entry.reverse) {
187
+ if (displayRoute.startsWith(trans + '/')) {
188
+ return canon + displayRoute.slice(trans.length)
189
+ }
190
+ }
191
+ return displayRoute
192
+ }
193
+
124
194
  /**
125
195
  * Build parent-child relationships between pages based on route structure
126
196
  * E.g., /getting-started/installation is a child of /getting-started
@@ -185,6 +255,9 @@ export default class Website {
185
255
  }
186
256
  }
187
257
 
258
+ // Reverse-translate display route to canonical (e.g., '/acerca-de' → '/about')
259
+ stripped = this.reverseTranslateRoute(stripped)
260
+
188
261
  // Normalize trailing slashes for consistent matching
189
262
  // '/about/' and '/about' should match the same page
190
263
  const normalizedRoute = stripped === '/' ? '/' : stripped.replace(/\/$/, '')
@@ -569,19 +642,35 @@ export default class Website {
569
642
  * @returns {string}
570
643
  */
571
644
  getLocaleUrl(localeCode, route = null) {
572
- const targetRoute = route || this.activePage?.route || '/'
645
+ let targetRoute = route || this.activePage?.route || '/'
646
+
647
+ // Strip current locale prefix if present in route
648
+ if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
649
+ const prefix = `/${this.activeLocale}`
650
+ if (targetRoute === prefix || targetRoute === `${prefix}/`) {
651
+ targetRoute = '/'
652
+ } else if (targetRoute.startsWith(`${prefix}/`)) {
653
+ targetRoute = targetRoute.slice(prefix.length)
654
+ }
655
+ }
656
+
657
+ // Reverse-translate from current locale to canonical route
658
+ targetRoute = this.reverseTranslateRoute(targetRoute)
573
659
 
574
- // Default locale uses root path (no prefix)
660
+ // Default locale uses root path (no prefix), no translation needed
575
661
  if (localeCode === this.defaultLocale) {
576
662
  return targetRoute
577
663
  }
578
664
 
665
+ // Translate canonical route to target locale's display route
666
+ const translatedRoute = this.translateRoute(targetRoute, localeCode)
667
+
579
668
  // Other locales use /locale/ prefix
580
- if (targetRoute === '/') {
669
+ if (translatedRoute === '/') {
581
670
  return `/${localeCode}/`
582
671
  }
583
672
 
584
- return `/${localeCode}${targetRoute}`
673
+ return `/${localeCode}${translatedRoute}`
585
674
  }
586
675
 
587
676
  /**
@@ -776,19 +865,23 @@ export default class Website {
776
865
  // Already sorted by order in constructor, so no need to re-sort
777
866
 
778
867
  // Build page info objects
779
- const buildPageInfo = (page) => ({
780
- id: page.id,
781
- route: page.getNavRoute(), // Use canonical nav route (e.g., '/' for index pages)
782
- navigableRoute: page.getNavigableRoute(), // First route with content (for links)
783
- title: page.title,
784
- label: page.getLabel(),
785
- description: page.description,
786
- hasContent: page.hasContent(),
787
- version: page.version || null, // Version metadata for filtering by version
788
- children: nested && page.hasChildren()
789
- ? page.children.filter(isPageVisible).map(buildPageInfo)
790
- : []
791
- })
868
+ const buildPageInfo = (page) => {
869
+ const navRoute = page.getNavRoute()
870
+ return {
871
+ id: page.id,
872
+ route: navRoute, // Use canonical nav route (e.g., '/' for index pages)
873
+ navigableRoute: page.getNavigableRoute(), // First route with content (for links)
874
+ translatedRoute: this.translateRoute(navRoute), // Locale-specific display route
875
+ title: page.title,
876
+ label: page.getLabel(),
877
+ description: page.description,
878
+ hasContent: page.hasContent(),
879
+ version: page.version || null, // Version metadata for filtering by version
880
+ children: nested && page.hasChildren()
881
+ ? page.children.filter(isPageVisible).map(buildPageInfo)
882
+ : []
883
+ }
884
+ }
792
885
 
793
886
  return filteredPages.map(buildPageInfo)
794
887
  }
@@ -872,7 +965,21 @@ export default class Website {
872
965
  * website.normalizeRoute('/') // ''
873
966
  */
874
967
  normalizeRoute(route) {
875
- return (route || '').replace(/^\/+/, '').replace(/\/+$/, '')
968
+ let normalized = (route || '').replace(/^\/+/, '').replace(/\/+$/, '')
969
+ // Strip locale prefix so '/es/about' normalizes to 'about'
970
+ if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
971
+ const prefix = this.activeLocale
972
+ if (normalized === prefix) {
973
+ normalized = ''
974
+ } else if (normalized.startsWith(`${prefix}/`)) {
975
+ normalized = normalized.slice(prefix.length + 1)
976
+ }
977
+ }
978
+ // Reverse-translate display route to canonical (e.g., 'acerca-de' → 'about')
979
+ const withSlash = '/' + normalized
980
+ const reversed = this.reverseTranslateRoute(withSlash)
981
+ normalized = reversed.replace(/^\//, '')
982
+ return normalized
876
983
  }
877
984
 
878
985
  /**