@uniweb/core 0.3.6 → 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 +139 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.3.6",
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
@@ -172,9 +242,25 @@ export default class Website {
172
242
  * @returns {Page|undefined}
173
243
  */
174
244
  getPage(route) {
245
+ // Strip locale prefix if present (e.g., '/fr/about' → '/about')
246
+ // Pages are stored with non-prefixed routes; the locale is a URL concern,
247
+ // not a page identity concern.
248
+ let stripped = route
249
+ if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
250
+ const prefix = `/${this.activeLocale}`
251
+ if (stripped === prefix || stripped === `${prefix}/`) {
252
+ stripped = '/'
253
+ } else if (stripped.startsWith(`${prefix}/`)) {
254
+ stripped = stripped.slice(prefix.length)
255
+ }
256
+ }
257
+
258
+ // Reverse-translate display route to canonical (e.g., '/acerca-de' → '/about')
259
+ stripped = this.reverseTranslateRoute(stripped)
260
+
175
261
  // Normalize trailing slashes for consistent matching
176
262
  // '/about/' and '/about' should match the same page
177
- const normalizedRoute = route === '/' ? '/' : route.replace(/\/$/, '')
263
+ const normalizedRoute = stripped === '/' ? '/' : stripped.replace(/\/$/, '')
178
264
 
179
265
  // Priority 1: Exact match on actual route
180
266
  const exactMatch = this.pages.find((page) => page.route === normalizedRoute)
@@ -556,19 +642,35 @@ export default class Website {
556
642
  * @returns {string}
557
643
  */
558
644
  getLocaleUrl(localeCode, route = null) {
559
- 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
+ }
560
656
 
561
- // Default locale uses root path (no prefix)
657
+ // Reverse-translate from current locale to canonical route
658
+ targetRoute = this.reverseTranslateRoute(targetRoute)
659
+
660
+ // Default locale uses root path (no prefix), no translation needed
562
661
  if (localeCode === this.defaultLocale) {
563
662
  return targetRoute
564
663
  }
565
664
 
665
+ // Translate canonical route to target locale's display route
666
+ const translatedRoute = this.translateRoute(targetRoute, localeCode)
667
+
566
668
  // Other locales use /locale/ prefix
567
- if (targetRoute === '/') {
669
+ if (translatedRoute === '/') {
568
670
  return `/${localeCode}/`
569
671
  }
570
672
 
571
- return `/${localeCode}${targetRoute}`
673
+ return `/${localeCode}${translatedRoute}`
572
674
  }
573
675
 
574
676
  /**
@@ -763,19 +865,23 @@ export default class Website {
763
865
  // Already sorted by order in constructor, so no need to re-sort
764
866
 
765
867
  // Build page info objects
766
- const buildPageInfo = (page) => ({
767
- id: page.id,
768
- route: page.getNavRoute(), // Use canonical nav route (e.g., '/' for index pages)
769
- navigableRoute: page.getNavigableRoute(), // First route with content (for links)
770
- title: page.title,
771
- label: page.getLabel(),
772
- description: page.description,
773
- hasContent: page.hasContent(),
774
- version: page.version || null, // Version metadata for filtering by version
775
- children: nested && page.hasChildren()
776
- ? page.children.filter(isPageVisible).map(buildPageInfo)
777
- : []
778
- })
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
+ }
779
885
 
780
886
  return filteredPages.map(buildPageInfo)
781
887
  }
@@ -859,7 +965,21 @@ export default class Website {
859
965
  * website.normalizeRoute('/') // ''
860
966
  */
861
967
  normalizeRoute(route) {
862
- 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
863
983
  }
864
984
 
865
985
  /**