@uniweb/build 0.63.4 → 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,9 +64,11 @@ 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'
@@ -75,6 +76,7 @@ import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeCon
75
76
  import { unwrapLocalized } from './backfill.js'
76
77
  import { loadFreeformTranslation } from '../i18n/freeform.js'
77
78
  import { resolveLocaleList } from '../i18n/locales.js'
79
+ import { translationContext } from '../i18n/extract.js'
78
80
  import { updateBackendState, readBackendState } from './sync-store.js'
79
81
  import { upsertYamlScalar } from './yaml-upsert.js'
80
82
  import { resolveQueriesConfig } from './queries-config.js'
@@ -142,6 +144,89 @@ function stripCredentials(block, label) {
142
144
  // processMarkdownFile only destructures type/component/preset/input/props/
143
145
  // fetch/data/id out of frontmatter, so `background:` and `theme:` stay
144
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
+
145
230
  // Post-pass: wrap every section's `content` (page sections + their `$children`,
146
231
  // recursing into child pages, plus layout sections) into per-locale form. Mutates
147
232
  // the records in place. Only called for multi-locale sites. Each target locale is
@@ -154,9 +239,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
154
239
 
155
240
  const localizeSection = async (record, page) => {
156
241
  if (!record.content) return
157
- 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))
158
247
  if (page) {
159
- const section = { stableId: record.stable_id || record.$id }
248
+ const section = { stableId }
160
249
  for (const locale of targetLocales) {
161
250
  // loadFreeformTranslation returns { content, frontmatter, … } — the doc is `.content`.
162
251
  const body = (await loadFreeformTranslation(section, page, locale, freeformBase))?.content
@@ -176,19 +265,36 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
176
265
  if (Array.isArray(s.$children)) await visitSections(s.$children, page)
177
266
  }
178
267
  }
179
- 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) => {
180
272
  for (const p of pgs || []) {
181
- // `slug` is a localized `{lang:value}` map; the free-form route is the
182
- // canonical (source-locale) path, so unwrap before building it.
183
- const slug = unwrapLocalized(p.slug, sourceLocale)
184
- const route = routePrefix ? `${routePrefix}/${slug}` : slug
273
+ const route = buildRouteOf(p, parentRoute, sourceLocale)
185
274
  const page = { route, id: p.stable_id }
275
+ localizePageMeta(p, route, sourceLocale, translations)
186
276
  if (Array.isArray(p.page_sections)) await visitSections(p.page_sections, page)
187
277
  if (Array.isArray(p.$children)) await visitPages(p.$children, route)
188
278
  }
189
279
  }
190
- await visitPages(pages, '')
191
- 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)
192
298
  }
193
299
 
194
300
  function mapSectionData(section) {
@@ -198,7 +304,7 @@ function mapSectionData(section) {
198
304
  delete params.background
199
305
  delete params.theme
200
306
 
201
- const data = { type: section.type || 'Content' } // entity type requires `type`
307
+ const data = { type: section.type || FILLED_SECTION_TYPE } // entity type requires `type`
202
308
  setIf(data, 'stable_id', section.stableId ?? undefined)
203
309
  setIf(data, 'preset', section.preset ?? undefined)
204
310
  setIf(data, 'input', section.input ?? undefined)
@@ -215,10 +321,10 @@ function buildPageData(config, ctx) {
215
321
  const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
216
322
  ctx
217
323
  // The page `slug` is the localized route source — a `{lang: slug}` map (the
218
- // site-content Model declares it localized; greenlit 2026-06-13). A single-locale
219
- // site emits one entry (`{ en: "home" }`); per-locale slug overrides for
220
- // multi-locale localized routes (from i18n.routeTranslations) are follow-on
221
- // 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.
222
328
  const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
223
329
  setIf(data, 'stable_id', config.id)
224
330
  setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
@@ -351,21 +457,14 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
351
457
  order: typeof config.order === 'number' ? config.order : undefined,
352
458
  })
353
459
  }
354
- // Mirror content-collector's pageFolders sort: explicit order, then
355
- // numeric-prefix filename order.
356
- folders.sort(
357
- (a, b) =>
358
- (a.order ?? Infinity) - (b.order ?? Infinity) ||
359
- compareFilenames(a.dirName, b.dirName)
360
- )
361
- // Then the parent's `pages:` wildcard, exactly as a normal build applies it.
362
- if (Array.isArray(parentConfig?.pages)) {
363
- const parsed = parseWildcardArray(parentConfig.pages)
364
- if (parsed && parsed.mode !== 'all') {
365
- return applyWildcardOrder(folders, parsed)
366
- }
367
- }
368
- 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)
369
468
  }
370
469
 
371
470
  const DYNAMIC_RE = /^\[(.+)\]$/
@@ -669,6 +768,9 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
669
768
  // natural handle — spec default). The path is NEVER the identity.
670
769
  const id = data.stable_id || slug
671
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
672
774
 
673
775
  if (mode === 'page') {
674
776
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
@@ -1136,7 +1238,7 @@ function secretsNested(provisioned) {
1136
1238
  // ROUND-TRIP LAW). `site-project.js::SETTINGS_TO_SITE_YML` plus its explicit
1137
1239
  // branches is the other half, and `producer-list-drift.test.js` fails if a key
1138
1240
  // emitted here has neither.
1139
- function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations } = {}) {
1241
+ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages = null } = {}) {
1140
1242
  const settings = {}
1141
1243
 
1142
1244
  // Site-wide values an author declares once and references from page content as
@@ -1170,7 +1272,13 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1170
1272
  // ⇒ `settings` is where they live. `publish_languages` rides VERBATIM, dangling
1171
1273
  // codes included: sync carries the full working set and only *publish* filters,
1172
1274
  // which preserves a locale's publish intent across a remove + re-add in `languages:`.
1173
- 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)
1174
1282
  setIf(settings, 'default_language', siteYml.defaultLanguage)
1175
1283
  setIf(settings, 'publish_languages', siteYml.publishLanguages)
1176
1284
 
@@ -1506,6 +1614,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1506
1614
  // — as the build does — pushed no homepage at all.
1507
1615
  const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
1508
1616
  pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
1617
+ localizeRouteSlugs(pages, siteYml, sourceLocale)
1509
1618
  }
1510
1619
 
1511
1620
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
@@ -1539,7 +1648,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1539
1648
  doc.$schema = SITE_MODEL_NAME
1540
1649
  doc.info = info
1541
1650
  // Emitted only when the file declares something — see `settingsNested`.
1542
- 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 })
1543
1654
  if (settings) doc.settings = settings
1544
1655
  doc.pages = pages
1545
1656
  doc.layout_sections = layoutSections