@uniweb/build 0.63.3 → 0.64.0

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/src/uwx/site.js CHANGED
@@ -56,8 +56,7 @@ import {
56
56
  isIgnoredFolder,
57
57
  parseNumericPrefix,
58
58
  compareFilenames,
59
- parseWildcardArray,
60
- applyWildcardOrder,
59
+ orderFolders,
61
60
  processMarkdownFile,
62
61
  declaredFetch,
63
62
  checkDeclaration,
@@ -65,15 +64,19 @@ import {
65
64
  assertRouteFolder,
66
65
  mountEntriesOf,
67
66
  rootOrderConfig,
67
+ composeLocalizedRoute,
68
+ isValidSlugSegment,
68
69
  } from '../site/content-collector.js'
69
70
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
70
- import { readLayoutFolder } from '../site/layout-folder.js'
71
+ import { readLayoutFolder, layoutAreaRoute } from '../site/layout-folder.js'
71
72
  import { normalizeHideIn } from '../site/nav-visibility.js'
72
73
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
73
74
  import { emitEntitySyncPackage } from './entity-document.js'
74
75
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
75
76
  import { unwrapLocalized } from './backfill.js'
76
77
  import { loadFreeformTranslation } from '../i18n/freeform.js'
78
+ import { resolveLocaleList } from '../i18n/locales.js'
79
+ import { translationContext } from '../i18n/extract.js'
77
80
  import { updateBackendState, readBackendState } from './sync-store.js'
78
81
  import { upsertYamlScalar } from './yaml-upsert.js'
79
82
  import { resolveQueriesConfig } from './queries-config.js'
@@ -141,6 +144,89 @@ function stripCredentials(block, label) {
141
144
  // processMarkdownFile only destructures type/component/preset/input/props/
142
145
  // fetch/data/id out of frontmatter, so `background:` and `theme:` stay
143
146
  // inside section.params — lift them into the entity type's dedicated fields.
147
+ // The `type` a push sends for a section whose file names none — the Model requires one. A pull does
148
+ // not write it back into a file that names none (`sectionRecordToFile`): absent, the foundation's
149
+ // `defaultSection` renders it, which is not always `Content`.
150
+ export const FILLED_SECTION_TYPE = 'Content'
151
+
152
+ const AUTHORED_SLUG = Symbol('authoredSlug')
153
+ const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
154
+
155
+ /**
156
+ * Post-pass: a page's localized URL segments ride in its localized `slug`, beside the source one —
157
+ * the slot the site-content Model declares for them. From the form the site keeps them in, read as
158
+ * the build reads it (`buildRouteTranslations`): the pages' own `slug: { <locale>: <segment> }` when
159
+ * any page declares one, else `site.yml`'s `i18n.routeTranslations`, a whole route per locale, taken
160
+ * apart into each page's segment. ⛔ Until 2026-09-26 only the source slug was sent, so a clone lost
161
+ * every localized URL — measured on the `international` template, whose `/acerca-de` came back as
162
+ * `/about`.
163
+ *
164
+ * A route whose localized form is not its parent's plus one segment cannot be said per page, and is
165
+ * named rather than dropped in silence. Mutates the records in place.
166
+ */
167
+ function localizeRouteSlugs(pages, siteYml, sourceLocale) {
168
+ const languages = Array.isArray(siteYml?.languages) ? new Set(siteYml.languages) : null
169
+ const carried = (locale, segment) =>
170
+ locale !== sourceLocale && (!languages || languages.has(locale)) && isValidSlugSegment(segment)
171
+ // Each page with the route the build gives it (`buildRouteOf`).
172
+ const visit = (records, parentRoute, fn) => {
173
+ for (const record of records || []) {
174
+ const route = buildRouteOf(record, parentRoute, sourceLocale)
175
+ fn(record, route, parentRoute)
176
+ visit(record.$children, route, fn)
177
+ }
178
+ }
179
+ let ownForm = false
180
+ visit(pages, '/', (record) => { if (record[AUTHORED_SLUG]) ownForm = true })
181
+ if (ownForm) {
182
+ visit(pages, '/', (record) => {
183
+ for (const [locale, segment] of Object.entries(record[AUTHORED_SLUG] || {})) {
184
+ if (carried(locale, segment)) record.slug[locale] = segment
185
+ }
186
+ })
187
+ return
188
+ }
189
+ const map = siteYml?.i18n?.routeTranslations
190
+ if (!isPlainRecord(map)) return
191
+ const segments = new Map() // canonical route → { locale: segment }, as the build composes them
192
+ visit(pages, '/', (record, route, parentRoute) => {
193
+ if (record.is_dynamic || record.is_index) return
194
+ for (const [locale, table] of Object.entries(map)) {
195
+ const display = isPlainRecord(table) ? table[route] : undefined
196
+ if (typeof display !== 'string') continue
197
+ const parts = display.split('/').filter(Boolean)
198
+ const segment = parts.pop()
199
+ if (`/${parts.join('/')}` !== composeLocalizedRoute(parentRoute, locale, segments)) {
200
+ console.warn(
201
+ `uwx/site: \`i18n.routeTranslations\` gives ${route} the ${locale} URL ${display}, which is not ` +
202
+ `its parent's plus one segment — it is not sent. Give the page \`slug: { ${locale}: … }\` instead.`
203
+ )
204
+ continue
205
+ }
206
+ if (!carried(locale, segment)) continue
207
+ record.slug[locale] = segment
208
+ segments.set(route, { ...(segments.get(route) || {}), [locale]: segment })
209
+ }
210
+ })
211
+ }
212
+
213
+ /**
214
+ * The route the build gives a page record: an index page takes its parent's, a parametric one is
215
+ * `:<param>` (`[...path]` is `:path*`), any other its slug under its parent. One rule, exported for the
216
+ * pull (`site-project.js`), which must address a page as the push does.
217
+ *
218
+ * @param {object} record - a page record (`slug`, `is_index`, `is_dynamic`)
219
+ * @param {string} parentRoute - its parent's route, `/` at the top
220
+ * @param {string} sourceLocale
221
+ * @returns {string}
222
+ */
223
+ export function buildRouteOf(record, parentRoute, sourceLocale) {
224
+ if (record?.is_index) return parentRoute
225
+ const slug = unwrapLocalized(record?.slug, sourceLocale)
226
+ const segment = record?.is_dynamic ? (slug === CATCH_ALL_MARKER ? ':path*' : `:${slug}`) : slug
227
+ return parentRoute === '/' ? `/${segment}` : `${parentRoute}/${segment}`
228
+ }
229
+
144
230
  // Post-pass: wrap every section's `content` (page sections + their `$children`,
145
231
  // recursing into child pages, plus layout sections) into per-locale form. Mutates
146
232
  // the records in place. Only called for multi-locale sites. Each target locale is
@@ -153,9 +239,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
153
239
 
154
240
  const localizeSection = async (record, page) => {
155
241
  if (!record.content) return
156
- let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations)
242
+ const stableId = record.stable_id || record.$id
243
+ // Resolved in the section's context, as the build resolves it (`translationContext`): a
244
+ // context-specific override applies here and nowhere else. ⛔ Until 2026-09-26 every section was
245
+ // resolved with none, so an override never reached a backend.
246
+ let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route))
157
247
  if (page) {
158
- const section = { stableId: record.stable_id || record.$id }
248
+ const section = { stableId }
159
249
  for (const locale of targetLocales) {
160
250
  // loadFreeformTranslation returns { content, frontmatter, … } — the doc is `.content`.
161
251
  const body = (await loadFreeformTranslation(section, page, locale, freeformBase))?.content
@@ -175,19 +265,36 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
175
265
  if (Array.isArray(s.$children)) await visitSections(s.$children, page)
176
266
  }
177
267
  }
178
- const visitPages = async (pgs, routePrefix) => {
268
+ // A page is addressed by the route the build gives it (`buildRouteOf`): a free-form translation is
269
+ // looked up by it, and a translation's context names it. ⛔ Until 2026-09-26 this was the slug path,
270
+ // so a homepage was `home` where the build reads `/`, and its sections' free-form files were never sent.
271
+ const visitPages = async (pgs, parentRoute) => {
179
272
  for (const p of pgs || []) {
180
- // `slug` is a localized `{lang:value}` map; the free-form route is the
181
- // canonical (source-locale) path, so unwrap before building it.
182
- const slug = unwrapLocalized(p.slug, sourceLocale)
183
- const route = routePrefix ? `${routePrefix}/${slug}` : slug
273
+ const route = buildRouteOf(p, parentRoute, sourceLocale)
184
274
  const page = { route, id: p.stable_id }
275
+ localizePageMeta(p, route, sourceLocale, translations)
185
276
  if (Array.isArray(p.page_sections)) await visitSections(p.page_sections, page)
186
277
  if (Array.isArray(p.$children)) await visitPages(p.$children, route)
187
278
  }
188
279
  }
189
- await visitPages(pages, '')
190
- await visitSections(layoutSections, null) // layout sections have no free-form home
280
+ await visitPages(pages, '/')
281
+ // A layout area's section has its free-form home at the area's route, as the build reads it
282
+ // (`layoutAreaRoute`). ⛔ This said "layout sections have no free-form home" until 2026-09-26.
283
+ for (const s of layoutSections || []) await localizeSection(s, { route: layoutAreaRoute(s.layout_name, s.area || s.stable_id) })
284
+ }
285
+
286
+ // ⭐ A page's title, label, description and keywords are resolved in the page's context, as the
287
+ // build translates them (`i18n/merge.js::translatePageMeta`, `<route>:_meta`): an override for one
288
+ // page applies there and nowhere else. ⛔ Until 2026-09-26 they were resolved with no page, so an
289
+ // override never reached a backend — nor, until the same day, an entry's default.
290
+ function localizePageMeta(page, route, sourceLocale, translations) {
291
+ const context = { page: route, section: '_meta' }
292
+ const again = (value) =>
293
+ value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
294
+ ? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
295
+ : value
296
+ for (const key of ['title', 'label', 'description']) if (page[key] !== undefined) page[key] = again(page[key])
297
+ if (Array.isArray(page.keywords)) page.keywords = page.keywords.map(again)
191
298
  }
192
299
 
193
300
  function mapSectionData(section) {
@@ -197,7 +304,7 @@ function mapSectionData(section) {
197
304
  delete params.background
198
305
  delete params.theme
199
306
 
200
- const data = { type: section.type || 'Content' } // entity type requires `type`
307
+ const data = { type: section.type || FILLED_SECTION_TYPE } // entity type requires `type`
201
308
  setIf(data, 'stable_id', section.stableId ?? undefined)
202
309
  setIf(data, 'preset', section.preset ?? undefined)
203
310
  setIf(data, 'input', section.input ?? undefined)
@@ -214,10 +321,10 @@ function buildPageData(config, ctx) {
214
321
  const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
215
322
  ctx
216
323
  // The page `slug` is the localized route source — a `{lang: slug}` map (the
217
- // site-content Model declares it localized; greenlit 2026-06-13). A single-locale
218
- // site emits one entry (`{ en: "home" }`); per-locale slug overrides for
219
- // multi-locale localized routes (from i18n.routeTranslations) are follow-on
220
- // producer work. `mode` is the plain delivery mode.
324
+ // site-content Model declares it localized; greenlit 2026-06-13). The source
325
+ // entry is the folder name; the other locales' segments are added by
326
+ // `localizeRouteSlugs`, once the whole tree is known. `mode` is the plain
327
+ // delivery mode.
221
328
  const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
222
329
  setIf(data, 'stable_id', config.id)
223
330
  setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
@@ -350,21 +457,14 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
350
457
  order: typeof config.order === 'number' ? config.order : undefined,
351
458
  })
352
459
  }
353
- // Mirror content-collector's pageFolders sort: explicit order, then
354
- // numeric-prefix filename order.
355
- folders.sort(
356
- (a, b) =>
357
- (a.order ?? Infinity) - (b.order ?? Infinity) ||
358
- compareFilenames(a.dirName, b.dirName)
359
- )
360
- // Then the parent's `pages:` wildcard, exactly as a normal build applies it.
361
- if (Array.isArray(parentConfig?.pages)) {
362
- const parsed = parseWildcardArray(parentConfig.pages)
363
- if (parsed && parsed.mode !== 'all') {
364
- return applyWildcardOrder(folders, parsed)
365
- }
366
- }
367
- return folders
460
+ // Explicit order, then numeric-prefix filename order, then the parent's `pages:` wildcard —
461
+ // one function, which the pull reproduces a level's order with too (`site-project.js`).
462
+ // ⚠️ It does NOT match the build's own page-folder sort in one case, and the note that said
463
+ // "mirror content-collector's pageFolders sort" here read as if it did: the collector compares
464
+ // a level's folder names whole, as strings, and matches `pages:` against them whole, where
465
+ // this strips a numeric prefix (`2-about` is `about`, and `10-` sorts after `2-`). Only a level
466
+ // with numbered page folders can tell them apart, and which rule is right for them is open.
467
+ return orderFolders(folders, parentConfig?.pages)
368
468
  }
369
469
 
370
470
  const DYNAMIC_RE = /^\[(.+)\]$/
@@ -668,6 +768,9 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
668
768
  // natural handle — spec default). The path is NEVER the identity.
669
769
  const id = data.stable_id || slug
670
770
  const record = withIdentity(id, data)
771
+ // A page's own localized URL segments, for `localizeRouteSlugs` — under a symbol, so they
772
+ // are never serialized as a field of the record.
773
+ if (!dyn && isPlainRecord(f.config?.slug)) record[AUTHORED_SLUG] = f.config.slug
671
774
 
672
775
  if (mode === 'page') {
673
776
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
@@ -1135,7 +1238,7 @@ function secretsNested(provisioned) {
1135
1238
  // ROUND-TRIP LAW). `site-project.js::SETTINGS_TO_SITE_YML` plus its explicit
1136
1239
  // branches is the other half, and `producer-list-drift.test.js` fails if a key
1137
1240
  // emitted here has neither.
1138
- function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations } = {}) {
1241
+ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages = null } = {}) {
1139
1242
  const settings = {}
1140
1243
 
1141
1244
  // Site-wide values an author declares once and references from page content as
@@ -1169,7 +1272,13 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1169
1272
  // ⇒ `settings` is where they live. `publish_languages` rides VERBATIM, dangling
1170
1273
  // codes included: sync carries the full working set and only *publish* filters,
1171
1274
  // which preserves a locale's publish intent across a remove + re-add in `languages:`.
1172
- setIf(settings, 'languages', siteYml.languages)
1275
+ // ⭐ A site that declares no `languages:` has the languages its translation files make — the build's
1276
+ // rule (`i18n/locales.js`) — and sends that list (`languages`, below), as it sends a derived
1277
+ // `deferred:`: a host may serve a site in the languages it declares and no others, and the site's
1278
+ // localized URLs with them. The pull does not write it back where the files already say it
1279
+ // (`siteContentDocumentToProject`). ⛔ Until 2026-09-26 only a declared list was sent, so the
1280
+ // `international` template, which declares none, sent no language but its source.
1281
+ setIf(settings, 'languages', siteYml.languages ?? languages ?? undefined)
1173
1282
  setIf(settings, 'default_language', siteYml.defaultLanguage)
1174
1283
  setIf(settings, 'publish_languages', siteYml.publishLanguages)
1175
1284
 
@@ -1287,7 +1396,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1287
1396
  // Target-locale translations (locales/{locale}.json) for wrapping localized
1288
1397
  // scalars back into per-locale form. Source-locale-only when no target locales /
1289
1398
  // no locale files exist (single-locale sites are unaffected).
1290
- const targetLocales = (Array.isArray(siteYml.languages) ? siteYml.languages : []).filter(
1399
+ // ⭐ The site's locales by the build's own rule (`i18n/locales.js`): an explicit
1400
+ // `languages:` list, else every translation file. ⛔ Until 2026-09-25 only an explicit list
1401
+ // counted, so a site with none — the `international` template — pushed no translations.
1402
+ const targetLocales = resolveLocaleList(siteYml.languages, localesDir(siteRoot)).filter(
1291
1403
  (l) => l !== sourceLocale
1292
1404
  )
1293
1405
  const translations = targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales) : null
@@ -1502,6 +1614,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1502
1614
  // — as the build does — pushed no homepage at all.
1503
1615
  const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
1504
1616
  pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
1617
+ localizeRouteSlugs(pages, siteYml, sourceLocale)
1505
1618
  }
1506
1619
 
1507
1620
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
@@ -1535,7 +1648,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1535
1648
  doc.$schema = SITE_MODEL_NAME
1536
1649
  doc.info = info
1537
1650
  // Emitted only when the file declares something — see `settingsNested`.
1538
- const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations })
1651
+ // The languages the site's translation files make, for a site that declares none (`settingsNested`).
1652
+ const languages = targetLocales.length > 0 ? [sourceLocale, ...targetLocales] : null
1653
+ const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages })
1539
1654
  if (settings) doc.settings = settings
1540
1655
  doc.pages = pages
1541
1656
  doc.layout_sections = layoutSections