@uniweb/build 0.4.4 → 0.4.6

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/build",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,8 +50,8 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/runtime": "0.5.7",
54
- "@uniweb/content-reader": "1.1.2"
53
+ "@uniweb/content-reader": "1.1.2",
54
+ "@uniweb/runtime": "0.5.8"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.3.7"
63
+ "@uniweb/core": "0.3.9"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
@@ -178,7 +178,8 @@ function addUnit(units, source, field, context) {
178
178
  const hash = computeHash(source)
179
179
 
180
180
  if (units[hash]) {
181
- const existingContexts = units[hash].contexts
181
+ const existingContexts = units[hash].contexts || []
182
+ units[hash].contexts = existingContexts
182
183
  const contextKey = `${context.collection}:${context.item}`
183
184
  const exists = existingContexts.some(
184
185
  c => `${c.collection}:${c.item}` === contextKey
@@ -27,6 +27,27 @@ export function extractTranslatableContent(siteContent) {
27
27
  }
28
28
  }
29
29
 
30
+ // Extract from 404 page (stored as top-level notFound)
31
+ if (siteContent.notFound) {
32
+ const notFoundPage = siteContent.notFound
33
+ const pageRoute = notFoundPage.route || '/404'
34
+ extractFromPageMeta(notFoundPage, pageRoute, units)
35
+ for (const section of notFoundPage.sections || []) {
36
+ extractFromSection(section, pageRoute, units)
37
+ }
38
+ }
39
+
40
+ // Extract from shared layout pages (header, footer, left, right panels)
41
+ for (const layoutKey of ['header', 'footer', 'left', 'right']) {
42
+ const layoutPage = siteContent[layoutKey]
43
+ if (layoutPage?.sections) {
44
+ const pageRoute = layoutPage.route || `/@${layoutKey}`
45
+ for (const section of layoutPage.sections) {
46
+ extractFromSection(section, pageRoute, units)
47
+ }
48
+ }
49
+ }
50
+
30
51
  return {
31
52
  version: '1.0',
32
53
  defaultLocale: siteContent.config?.defaultLanguage || 'en',
@@ -50,6 +71,11 @@ function extractFromPageMeta(page, pageRoute, units) {
50
71
  addUnit(units, page.title, 'page.title', context)
51
72
  }
52
73
 
74
+ // Page label (short navigation label, distinct from title)
75
+ if (page.label && typeof page.label === 'string') {
76
+ addUnit(units, page.label, 'page.label', context)
77
+ }
78
+
53
79
  // Page description
54
80
  if (page.description && typeof page.description === 'string') {
55
81
  addUnit(units, page.description, 'page.description', context)
package/src/i18n/merge.js CHANGED
@@ -67,6 +67,26 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
67
67
  }
68
68
  }
69
69
 
70
+ // Translate 404 page (stored as top-level notFound)
71
+ if (translated.notFound) {
72
+ const pageRoute = translated.notFound.route || '/404'
73
+ translatePageMeta(translated.notFound, pageRoute, translations, fallbackToSource)
74
+ for (const section of translated.notFound.sections || []) {
75
+ translateSectionSync(section, pageRoute, translations, fallbackToSource)
76
+ }
77
+ }
78
+
79
+ // Translate shared layout sections (header, footer, sidebars)
80
+ for (const layoutKey of ['header', 'footer', 'left', 'right']) {
81
+ const layoutPage = translated[layoutKey]
82
+ if (layoutPage?.sections) {
83
+ const pageRoute = layoutPage.route || `/@${layoutKey}`
84
+ for (const section of layoutPage.sections) {
85
+ translateSectionSync(section, pageRoute, translations, fallbackToSource)
86
+ }
87
+ }
88
+ }
89
+
70
90
  return translated
71
91
  }
72
92
 
@@ -95,6 +115,35 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
95
115
  }
96
116
  }
97
117
 
118
+ // Translate 404 page (stored as top-level notFound)
119
+ if (translated.notFound) {
120
+ const pageRoute = translated.notFound.route || '/404'
121
+ translatePageMeta(translated.notFound, pageRoute, translations, fallbackToSource)
122
+ for (const section of translated.notFound.sections || []) {
123
+ await translateSectionAsync(section, translated.notFound, translations, {
124
+ fallbackToSource,
125
+ locale,
126
+ localesDir
127
+ })
128
+ }
129
+ }
130
+
131
+ // Translate shared layout sections (header, footer, sidebars)
132
+ for (const layoutKey of ['header', 'footer', 'left', 'right']) {
133
+ const layoutPage = translated[layoutKey]
134
+ if (layoutPage?.sections) {
135
+ // Ensure route is set for context matching (extract uses /@header, etc.)
136
+ if (!layoutPage.route) layoutPage.route = `/@${layoutKey}`
137
+ for (const section of layoutPage.sections) {
138
+ await translateSectionAsync(section, layoutPage, translations, {
139
+ fallbackToSource,
140
+ locale,
141
+ localesDir
142
+ })
143
+ }
144
+ }
145
+ }
146
+
98
147
  return translated
99
148
  }
100
149
 
@@ -109,6 +158,11 @@ function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
109
158
  page.title = lookupTranslation(page.title, context, translations, fallbackToSource)
110
159
  }
111
160
 
161
+ // Translate label (short navigation label)
162
+ if (page.label && typeof page.label === 'string') {
163
+ page.label = lookupTranslation(page.label, context, translations, fallbackToSource)
164
+ }
165
+
112
166
  // Translate description
113
167
  if (page.description && typeof page.description === 'string') {
114
168
  page.description = lookupTranslation(page.description, context, translations, fallbackToSource)
package/src/i18n/sync.js CHANGED
@@ -94,10 +94,12 @@ export function syncManifests(previous, current) {
94
94
  * Check if two context arrays are equal
95
95
  */
96
96
  function contextsEqual(contexts1, contexts2) {
97
- if (contexts1.length !== contexts2.length) return false
97
+ const c1 = contexts1 || []
98
+ const c2 = contexts2 || []
99
+ if (c1.length !== c2.length) return false
98
100
 
99
- const set1 = new Set(contexts1.map(c => `${c.page}:${c.section}`))
100
- const set2 = new Set(contexts2.map(c => `${c.page}:${c.section}`))
101
+ const set1 = new Set(c1.map(c => `${c.page || c.collection}:${c.section || c.item}`))
102
+ const set2 = new Set(c2.map(c => `${c.page || c.collection}:${c.section || c.item}`))
101
103
 
102
104
  if (set1.size !== set2.size) return false
103
105
  for (const key of set1) {
@@ -115,8 +117,9 @@ function findMatchingContext(currentContexts, previousUnits) {
115
117
  const contextKey = `${context.page}:${context.section}`
116
118
 
117
119
  for (const [hash, unit] of Object.entries(previousUnits)) {
118
- const hasContext = unit.contexts.some(
119
- c => `${c.page}:${c.section}` === contextKey
120
+ const unitContexts = unit.contexts || []
121
+ const hasContext = unitContexts.some(
122
+ c => `${c.page || c.collection}:${c.section || c.item}` === contextKey
120
123
  )
121
124
  if (hasContext) {
122
125
  return { hash, source: unit.source, contexts: unit.contexts }
package/src/prerender.js CHANGED
@@ -570,7 +570,9 @@ export async function prerenderSite(siteDir, options = {}) {
570
570
 
571
571
  for (const page of pages) {
572
572
  // Build the output route with locale prefix
573
- const outputRoute = routePrefix + page.route
573
+ // For non-default locales, translate route slugs (e.g., /about → /acerca-de)
574
+ const translatedPageRoute = isDefault ? page.route : website.translateRoute(page.route, locale)
575
+ const outputRoute = routePrefix + translatedPageRoute
574
576
 
575
577
  onProgress(`Rendering ${outputRoute}...`)
576
578
 
@@ -25,6 +25,34 @@ import { resolve, dirname, join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
  import { generateEntryPoint } from '../generate-entry.js'
27
27
 
28
+ /**
29
+ * Normalize a base path for Vite compatibility
30
+ *
31
+ * Handles common user mistakes:
32
+ * - Missing leading slash: "docs/" → "/docs/"
33
+ * - Missing trailing slash: "/docs" → "/docs/"
34
+ * - Extra slashes: "//docs///" → "/docs/"
35
+ * - Just a slash: "/" → undefined (root, no base needed)
36
+ *
37
+ * @param {string} raw - Raw base path from site.yml, env, or option
38
+ * @returns {string|undefined} Normalized path with leading+trailing slash, or undefined for root
39
+ */
40
+ function normalizeBasePath(raw) {
41
+ // Collapse repeated slashes and trim whitespace
42
+ let path = raw.trim().replace(/\/{2,}/g, '/')
43
+
44
+ // Ensure leading slash
45
+ if (!path.startsWith('/')) path = '/' + path
46
+
47
+ // Ensure trailing slash (Vite requirement)
48
+ if (!path.endsWith('/')) path = path + '/'
49
+
50
+ // Root path means no base needed
51
+ if (path === '/') return undefined
52
+
53
+ return path
54
+ }
55
+
28
56
  /**
29
57
  * Detect foundation type from the foundation config value
30
58
  *
@@ -143,9 +171,9 @@ export async function defineSiteConfig(options = {}) {
143
171
  const siteConfig = readSiteConfig(siteRoot)
144
172
 
145
173
  // Determine base path for deployment (priority: option > env > site.yml)
146
- // Ensures trailing slash for Vite compatibility
174
+ // Normalize: ensure leading slash, collapse repeated slashes, add trailing slash for Vite
147
175
  const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
148
- const base = rawBase ? (rawBase.endsWith('/') ? rawBase : `${rawBase}/`) : undefined
176
+ const base = rawBase ? normalizeBasePath(String(rawBase)) : undefined
149
177
 
150
178
  // Detect foundation type
151
179
  const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
@@ -117,10 +117,28 @@ async function processDevSectionFetches(sections, cascadedData, fetchOptions) {
117
117
  import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
118
118
  import { mergeTranslations } from '../i18n/merge.js'
119
119
 
120
+ /**
121
+ * Translate a canonical route for a given locale using route translations config
122
+ * Supports exact and prefix matching (e.g., /blog → /noticias also applies to /blog/post)
123
+ */
124
+ function applyRouteTranslation(route, locale, routeTranslations) {
125
+ const localeMap = routeTranslations?.[locale]
126
+ if (!localeMap) return route
127
+ // Exact match
128
+ if (localeMap[route]) return localeMap[route]
129
+ // Prefix match
130
+ for (const [canonical, translated] of Object.entries(localeMap)) {
131
+ if (route.startsWith(canonical + '/')) {
132
+ return translated + route.slice(canonical.length)
133
+ }
134
+ }
135
+ return route
136
+ }
137
+
120
138
  /**
121
139
  * Generate sitemap.xml content
122
140
  */
123
- function generateSitemap(pages, baseUrl, locales = []) {
141
+ function generateSitemap(pages, baseUrl, locales = [], routeTranslations = {}) {
124
142
  const urls = []
125
143
 
126
144
  for (const page of pages) {
@@ -137,7 +155,13 @@ function generateSitemap(pages, baseUrl, locales = []) {
137
155
  // Add hreflang entries for multi-locale sites
138
156
  if (locales.length > 1) {
139
157
  for (const locale of locales) {
140
- const localeLoc = locale.default ? loc : `${baseUrl}/${locale.code}${page.route === '/' ? '' : page.route}`
158
+ let localeLoc
159
+ if (locale.default) {
160
+ localeLoc = loc
161
+ } else {
162
+ const translatedRoute = page.route === '/' ? '' : applyRouteTranslation(page.route, locale.code, routeTranslations)
163
+ localeLoc = `${baseUrl}/${locale.code}${translatedRoute}`
164
+ }
141
165
  urlEntry += `\n <xhtml:link rel="alternate" hreflang="${locale.code}" href="${escapeXml(localeLoc)}" />`
142
166
  }
143
167
  // Add x-default pointing to default locale
@@ -664,7 +688,7 @@ export function siteContentPlugin(options = {}) {
664
688
  // Serve sitemap.xml in dev mode
665
689
  if (req.url === '/sitemap.xml' && seoEnabled && siteContent?.pages) {
666
690
  res.setHeader('Content-Type', 'application/xml')
667
- res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, seoOptions.locales))
691
+ res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, seoOptions.locales, siteContent.config?.i18n?.routeTranslations))
668
692
  return
669
693
  }
670
694
 
@@ -851,7 +875,7 @@ export function siteContentPlugin(options = {}) {
851
875
  // Generate SEO files if enabled
852
876
  if (seoEnabled && finalContent?.pages) {
853
877
  // Generate sitemap.xml
854
- const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, seoOptions.locales)
878
+ const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, seoOptions.locales, finalContent.config?.i18n?.routeTranslations)
855
879
  this.emitFile({
856
880
  type: 'asset',
857
881
  fileName: 'sitemap.xml',