@uniweb/build 0.14.10 → 0.14.11

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.14.10",
3
+ "version": "0.14.11",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,13 +59,13 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "0.1.3",
63
- "@uniweb/content-writer": "0.2.5"
62
+ "@uniweb/content-writer": "0.2.5",
63
+ "@uniweb/theming": "0.1.3"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.16",
66
+ "@uniweb/schemas": "0.2.3",
67
67
  "@uniweb/content-reader": "1.1.12",
68
- "@uniweb/schemas": "0.2.2"
68
+ "@uniweb/runtime": "0.8.16"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -1234,6 +1234,12 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1234
1234
  title: pageConfig.title || extractH1(hierarchicalSections[0]?.content) || prettifySlug(pageName),
1235
1235
  description: pageConfig.description || '',
1236
1236
  label: pageConfig.label || null, // Short label for navigation (defaults to title)
1237
+ // Localized URL slug overrides: { <locale>: <segment> }. Compiled into
1238
+ // config.i18n.routeTranslations by collectSiteContent, then stripped from
1239
+ // the page payload (build is authoritative; runtime hydrates the map).
1240
+ ...(pageConfig.slug && typeof pageConfig.slug === 'object' && !Array.isArray(pageConfig.slug)
1241
+ ? { slug: pageConfig.slug }
1242
+ : {}),
1237
1243
  lastModified: lastModified?.toISOString(),
1238
1244
 
1239
1245
  // Dynamic route metadata
@@ -2133,11 +2139,33 @@ export async function collectSiteContent(sitePath, options = {}) {
2133
2139
  const intelligenceConfig = await readYamlFile(join(sitePath, 'intelligence.yml'))
2134
2140
  const hasIntelligence = intelligenceConfig && Object.keys(intelligenceConfig).length > 0
2135
2141
 
2142
+ // Compile per-page `slug:` maps into config.i18n.routeTranslations
2143
+ // (canonical route → per-locale display route). Producer-only: the runtime
2144
+ // (@uniweb/core website.js) and the sitemap generator already consume this
2145
+ // map; nothing else produces it on the file-based lane. The default locale
2146
+ // keeps the canonical (folder-based) route. Strip the build-time `slug` from
2147
+ // pages afterward — the runtime hydrates the precomputed map, not per-page
2148
+ // slugs (see "Minimize Runtime Payload").
2149
+ const defaultLocale =
2150
+ siteConfig.defaultLanguage ||
2151
+ (Array.isArray(siteConfig.languages) ? siteConfig.languages[0] : null) ||
2152
+ 'en'
2153
+ const routeTranslations = buildRouteTranslations(pages, {
2154
+ defaultLocale,
2155
+ languages: Array.isArray(siteConfig.languages) ? siteConfig.languages : null,
2156
+ })
2157
+ for (const page of pages) {
2158
+ if (page.slug) delete page.slug
2159
+ }
2160
+
2136
2161
  return {
2137
2162
  config: {
2138
2163
  ...siteConfig,
2139
2164
  fetch: parseFetchConfig(siteConfig.fetch),
2140
2165
  ...(hasIntelligence && { intelligence: intelligenceConfig }),
2166
+ ...(routeTranslations
2167
+ ? { i18n: { ...(siteConfig.i18n || {}), routeTranslations } }
2168
+ : {}),
2141
2169
  },
2142
2170
  theme: {
2143
2171
  ...processedTheme,
@@ -2162,7 +2190,115 @@ export async function collectSiteContent(sitePath, options = {}) {
2162
2190
  // than the flattened collector output but reuses these primitives so
2163
2191
  // markdown→ProseMirror, ordering, and mode detection stay consistent with a
2164
2192
  // normal build. Additive: no existing behavior changes.
2193
+ /**
2194
+ * Whether a value is a usable single URL path segment (no slashes, no
2195
+ * whitespace). Localized slug segments must satisfy this to be applied.
2196
+ */
2197
+ function isValidSlugSegment(s) {
2198
+ return typeof s === 'string' && s.length > 0 && !s.includes('/') && !/\s/.test(s)
2199
+ }
2200
+
2201
+ /**
2202
+ * Compose the full localized display route for a canonical route in a locale,
2203
+ * substituting the localized segment for any ancestor (or self) that declares
2204
+ * one in `slugByRoute`. Falls back to the canonical segment where no valid
2205
+ * localized slug exists. E.g. with /blog→blogue and /blog/my-post→mon-article,
2206
+ * `/blog/my-post` (fr) → `/blogue/mon-article`.
2207
+ */
2208
+ function composeLocalizedRoute(canonicalRoute, locale, slugByRoute) {
2209
+ if (!canonicalRoute || canonicalRoute === '/') return canonicalRoute || '/'
2210
+ const segments = canonicalRoute.split('/').filter(Boolean)
2211
+ let cumulative = ''
2212
+ const out = []
2213
+ for (const seg of segments) {
2214
+ cumulative += `/${seg}`
2215
+ const localized = slugByRoute.get(cumulative)?.[locale]
2216
+ out.push(isValidSlugSegment(localized) ? localized : seg)
2217
+ }
2218
+ return '/' + out.join('/')
2219
+ }
2220
+
2221
+ /**
2222
+ * Build `config.i18n.routeTranslations` from per-page `slug:` maps.
2223
+ *
2224
+ * Author surface: a page declares `slug: { <locale>: <segment> }` in its
2225
+ * page.yml. The canonical route stays the folder name; for each non-default
2226
+ * locale the localized URL substitutes the declared segment(s). The runtime
2227
+ * (`@uniweb/core` website.js: translateRoute/getLocaleUrl/getPageHierarchy) and
2228
+ * the build sitemap already consume this map — this is its only producer on the
2229
+ * file-based lane. Children without their own slug inherit a localized ancestor
2230
+ * via the runtime's prefix-cascade, so only pages that declare a slug get an
2231
+ * explicit entry.
2232
+ *
2233
+ * Pure + non-mutating. Returns `{ [locale]: { [canonicalRoute]: displayRoute } }`,
2234
+ * or null when no page declares a usable localized slug.
2235
+ *
2236
+ * @param {Array} pages - Flat page list (each with canonical `route` + optional `slug`).
2237
+ * @param {Object} [opts]
2238
+ * @param {string} [opts.defaultLocale='en'] - Default locale; its routes are the
2239
+ * canonical keys, so default-locale slugs are skipped (renaming the default
2240
+ * URL away from the folder name is a separate, heavier concern).
2241
+ * @param {string[]|null} [opts.languages=null] - Declared site locales; when an
2242
+ * array, slug locales outside it are skipped with a warning. null = no filter.
2243
+ * @returns {Object|null}
2244
+ */
2245
+ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null } = {}) {
2246
+ if (!Array.isArray(pages)) return null
2247
+
2248
+ // Index canonical route → { locale: segment } for pages declaring a slug map.
2249
+ const slugByRoute = new Map()
2250
+ for (const page of pages) {
2251
+ const slug = page?.slug
2252
+ if (slug && typeof slug === 'object' && !Array.isArray(slug) && page.route) {
2253
+ slugByRoute.set(page.route, slug)
2254
+ }
2255
+ }
2256
+ if (slugByRoute.size === 0) return null
2257
+
2258
+ const langSet = Array.isArray(languages) ? new Set(languages) : null
2259
+ const result = {}
2260
+
2261
+ for (const [canonicalRoute, localeMap] of slugByRoute) {
2262
+ for (const [locale, segment] of Object.entries(localeMap)) {
2263
+ // Default locale keeps the canonical (folder-based) route.
2264
+ if (locale === defaultLocale) continue
2265
+ if (langSet && !langSet.has(locale)) {
2266
+ console.warn(
2267
+ `[content-collector] slug locale '${locale}' on '${canonicalRoute}' is not in site languages — skipping`
2268
+ )
2269
+ continue
2270
+ }
2271
+ if (!isValidSlugSegment(segment)) {
2272
+ console.warn(
2273
+ `[content-collector] invalid slug '${segment}' for '${canonicalRoute}' (${locale}) — must be a single URL-safe path segment`
2274
+ )
2275
+ continue
2276
+ }
2277
+ const display = composeLocalizedRoute(canonicalRoute, locale, slugByRoute)
2278
+ ;(result[locale] ||= {})[canonicalRoute] = display
2279
+ }
2280
+ }
2281
+
2282
+ // Warn on within-locale display collisions (two canonical routes → same URL).
2283
+ for (const [locale, map] of Object.entries(result)) {
2284
+ const seen = new Map()
2285
+ for (const [canon, disp] of Object.entries(map)) {
2286
+ const prev = seen.get(disp)
2287
+ if (prev) {
2288
+ console.warn(
2289
+ `[content-collector] localized route collision in '${locale}': '${canon}' and '${prev}' both map to '${disp}'`
2290
+ )
2291
+ } else {
2292
+ seen.set(disp, canon)
2293
+ }
2294
+ }
2295
+ }
2296
+
2297
+ return Object.keys(result).length > 0 ? result : null
2298
+ }
2299
+
2165
2300
  export {
2301
+ buildRouteTranslations,
2166
2302
  extractItemName,
2167
2303
  parseWildcardArray,
2168
2304
  applyWildcardOrder,