@uniweb/build 0.74.2 → 0.74.4

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.74.2",
3
+ "version": "0.74.4",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/schemas": "^0.10.1",
61
- "@uniweb/projections": "^0.7.11",
62
- "@uniweb/content-writer": "^0.3.4",
63
60
  "@uniweb/content-reader": "^1.2.8",
61
+ "@uniweb/projections": "^0.7.11",
64
62
  "@uniweb/theming": "^0.2.0",
63
+ "@uniweb/content-writer": "^0.3.4",
64
+ "@uniweb/schemas": "^0.10.1",
65
65
  "@uniweb/semantic-parser": "^1.5.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.28.5"
68
+ "@uniweb/runtime": "^0.28.7"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/runtime": "^0.28.5",
78
- "@uniweb/core": "^0.35.0"
77
+ "@uniweb/core": "^0.35.2",
78
+ "@uniweb/runtime": "^0.28.7"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -30,6 +30,7 @@
30
30
  import { readFile, writeFile } from 'node:fs/promises'
31
31
  import { existsSync } from 'node:fs'
32
32
  import { join } from 'node:path'
33
+ import { resolveRoute } from '@uniweb/core/resolve-route'
33
34
 
34
35
  import { DeployError, spawnTool, readCredential, credentialHint } from './deploy-utils.js'
35
36
  import {
@@ -61,7 +62,13 @@ async function collectRoutingEntries(localeConfigs) {
61
62
  const prefix = localeConfig.routePrefix || ''
62
63
  for (const page of siteContent.pages || []) {
63
64
  if (page.redirect) {
64
- entries.push(`${prefix}${page.route} ${page.redirect} 302`)
65
+ // Where the app sends a visitor of this locale (`resolveRoute`): a page of the site in their
66
+ // language, anything else as written. ⛔ Until 2026-09-30 the target was written raw, so
67
+ // `/fr/old` redirected a French reader to the English page.
68
+ const route = `${prefix}${page.route}`
69
+ const resolved = resolveRoute(siteContent, route, { activeLocale: prefix ? prefix.slice(1) : undefined })
70
+ const location = resolved.kind === 'redirect' ? resolved.location : page.redirect
71
+ entries.push(`${route} ${location} 302`)
65
72
  }
66
73
  if (page.rewrite) {
67
74
  entries.push(`${prefix}${page.route}/* ${page.rewrite}/:splat 200`)
package/src/prerender.js CHANGED
@@ -279,10 +279,11 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
279
279
  *
280
280
  * Emitting the canonical route raw sent a reader who asked for `/fr/<container>`
281
281
  * to the DEFAULT-locale page — a language switch they never asked for, from a
282
- * URL that was correct. The runtime's copy of this redirect (PageRenderer.jsx)
283
- * had the identical bug and was fixed first; this is its twin, and the two must
284
- * agree or a container behaves differently on a cold load than on an in-app
285
- * navigation.
282
+ * URL that was correct. The in-app redirect had the identical bug and was fixed
283
+ * first. ⭐ Since 2026-09-30 that one is `resolveRoute`'s (`@uniweb/core/resolve-route`,
284
+ * `localeUrl`); this is the static twin of its destination — the same prefix and
285
+ * translated slug, with the deployment base this pass writes under — and the two
286
+ * must agree or a container behaves differently on a cold load than in the app.
286
287
  *
287
288
  * The DECISION to redirect stays canonical (`target !== page.route`) — only the
288
289
  * destination is localized. Comparing a localized destination against a
@@ -1002,10 +1003,18 @@ export async function prerenderSite(siteDir, options = {}) {
1002
1003
  const translatedPageRoute = isDefault ? page.route : website.translateRoute(page.route, locale)
1003
1004
  const outputRoute = routePrefix + translatedPageRoute
1004
1005
 
1005
- // Redirect pages: emit a redirect HTML instead of rendering content
1006
+ // Redirect pages: emit a redirect HTML instead of rendering content — to where the app sends
1007
+ // a visitor of this locale (`resolveRoute`): a page of the site in their language, anything
1008
+ // else as written. ⛔ Until 2026-09-30 the stub wrote the target raw, so a French reader was
1009
+ // sent to the English page and a relative target resolved against the stub's own URL.
1006
1010
  if (page.redirect) {
1007
- onProgress(` Redirect ${outputRoute} → ${page.redirect}`)
1008
- const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${page.redirect}"><link rel="canonical" href="${page.redirect}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${page.redirect}">${page.redirect}</a></p></body></html>`
1011
+ const resolved = website.resolveRoute(page.route)
1012
+ const location = resolved.kind === 'redirect' ? resolved.location : page.redirect
1013
+ const target = location.startsWith('/') && !location.startsWith('//')
1014
+ ? (website.basePath || '') + location
1015
+ : location
1016
+ onProgress(` Redirect ${outputRoute} → ${target}`)
1017
+ const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${target}">${target}</a></p></body></html>`
1009
1018
  const outputPath = getOutputPath(distDir, outputRoute)
1010
1019
  await mkdir(dirname(outputPath), { recursive: true })
1011
1020
  await writeFile(outputPath, redirectHtml)
@@ -1024,9 +1033,10 @@ export async function prerenderSite(siteDir, options = {}) {
1024
1033
  continue
1025
1034
  }
1026
1035
 
1027
- // Content-less containers: auto-redirect to first descendant with content.
1028
- // Mirrors the runtime's auto-redirect in PageRenderer.jsx so the redirect
1029
- // works without JS (via <meta http-equiv="refresh">).
1036
+ // Content-less containers: auto-redirect to first descendant with content —
1037
+ // `getNavigableRoute()`, which is `resolveRoute`'s landing rule
1038
+ // (`@uniweb/core/resolve-route`), so the stub goes where the app would.
1039
+ // A stub so the redirect works without JS (via <meta http-equiv="refresh">).
1030
1040
  if (!page.hasContent()) {
1031
1041
  const target = page.getNavigableRoute()
1032
1042
  if (target && target !== page.route) {
@@ -38,6 +38,7 @@ import { parseFetchConfig, toFetchList } from './data-fetcher.js'
38
38
  import { resolveExtensionUrls } from './extension-urls.js'
39
39
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
40
40
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery, currentFor } from '@uniweb/core'
41
+ import { authoredRedirectTarget } from '@uniweb/core/resolve-route'
41
42
  import { parseFrontmatter } from '../utils/frontmatter.js'
42
43
  import { parseGrid } from '@uniweb/schemas/grid'
43
44
 
@@ -1333,11 +1334,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1333
1334
  if (pageConfig.redirect) {
1334
1335
  const route = isIndex ? parentRoute
1335
1336
  : parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
1336
- const redirect = pageConfig.redirect
1337
- // Resolve relative redirects against the page's own route
1338
- const target = redirect.startsWith('/') || redirect.startsWith('http')
1339
- ? redirect
1340
- : (route === '/' ? `/${redirect}` : `${route}/${redirect}`)
1337
+ // A relative redirect resolves against the page's own route — core's rule, which a host
1338
+ // resolving a pushed payload applies too (`@uniweb/core/resolve-route`).
1339
+ const target = authoredRedirectTarget(route, pageConfig.redirect)
1341
1340
 
1342
1341
  return {
1343
1342
  page: {
@@ -839,7 +839,7 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
839
839
  // ⚖️ A pull adds and changes these, and never removes one: a page pushed by a CLI from before carries
840
840
  // none, and reading that as "none" would delete what the author wrote.
841
841
  function pageSlugToYml(record, canon, existing, sourceLocale, ctx) {
842
- if (record.is_dynamic) return null
842
+ if (record.is_dynamic) return dynamicOwnSlug(record, existing, sourceLocale)
843
843
  const targets = localizedTargets(record.slug, sourceLocale)
844
844
  const own = isPlainRecord(existing?.slug)
845
845
  if (!own && ctx.routes?.siteMap && !record.is_index) {
@@ -851,6 +851,20 @@ function pageSlugToYml(record, canon, existing, sourceLocale, ctx) {
851
851
  return own && stableJson(next) === stableJson(existing.slug) ? null : next
852
852
  }
853
853
 
854
+ // A parametric page's folder is named by its param (`pageDirName`), so its OWN slug — where a page
855
+ // made in an app gives it one of its own (`slug: detail`, `param_name: id`) — has no other place on
856
+ // disk. It is kept in `page.yml` as a plain string, which the build never reads for a route (only a
857
+ // per-language map names URL segments) and the push sends back as the page's slug. A string written
858
+ // here before is rewritten to the stored slug, so it is never left stale.
859
+ // ⛔ Until 2026-09-30 a pull dropped it, and the next push sent the param in its place: an authored
860
+ // value lost on a round trip (`uwx-format.md` § THE ROUND-TRIP LAW).
861
+ function dynamicOwnSlug(record, existing, sourceLocale) {
862
+ const own = unwrapLocalized(record.slug, sourceLocale)
863
+ if (typeof own !== 'string' || !own || own === '...path') return null
864
+ if (own !== (record.param_name || own)) return own
865
+ return typeof existing?.slug === 'string' ? own : null
866
+ }
867
+
854
868
  // The locales of a pulled localized `slug` other than the source one.
855
869
  function localizedTargets(slug, sourceLocale) {
856
870
  if (!isPlainRecord(slug)) return {}
package/src/uwx/site.js CHANGED
@@ -224,7 +224,15 @@ function localizeRouteSlugs(pages, siteYml, sourceLocale) {
224
224
  export function buildRouteOf(record, parentRoute, sourceLocale) {
225
225
  if (record?.is_index) return parentRoute
226
226
  const slug = unwrapLocalized(record?.slug, sourceLocale)
227
- const segment = record?.is_dynamic ? (slug === CATCH_ALL_MARKER ? ':path*' : `:${slug}`) : slug
227
+ // A parametric page's segment is its PARAM — the name the pull gives its folder, `[param_name]`
228
+ // (`site-project.js::pageDirName`), which is the route the build then reads. A page pushed from
229
+ // files has one word for both; a page made in an app may not (`slug: detail`, `param_name: id`).
230
+ // ⛔ Until 2026-09-30 this used the slug, so such a page was addressed `/…/:detail` while its
231
+ // folder built `/…/:id`, and its translated title and description were filed under a route no
232
+ // build reads.
233
+ const segment = record?.is_dynamic
234
+ ? (slug === CATCH_ALL_MARKER ? ':path*' : `:${record.param_name || slug}`)
235
+ : slug
228
236
  return parentRoute === '/' ? `/${segment}` : `${parentRoute}/${segment}`
229
237
  }
230
238
 
@@ -783,6 +791,12 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
783
791
  // A page's own localized URL segments, for `localizeRouteSlugs` — under a symbol, so they
784
792
  // are never serialized as a field of the record.
785
793
  if (!dyn && isPlainRecord(f.config?.slug)) record[AUTHORED_SLUG] = f.config.slug
794
+ // A parametric page's OWN slug, where it is not its param — a page made in an app — is kept in
795
+ // its page.yml by the pull as a plain string (`site-project.js::dynamicOwnSlug`), since its folder
796
+ // is named by its param. The route, the folder and the page's identity stay the param's.
797
+ if (dyn && dyn[1] !== CATCH_ALL_MARKER && typeof f.config?.slug === 'string' && f.config.slug) {
798
+ record.slug = { ...record.slug, [sourceLocale]: f.config.slug }
799
+ }
786
800
 
787
801
  if (mode === 'page') {
788
802
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)