@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/package.json +9 -9
- package/src/i18n/extract.js +24 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +22 -17
- package/src/i18n/records.js +389 -161
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/query-processor.js +13 -2
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/project-writer.js +106 -31
- package/src/uwx/records-project.js +108 -21
- package/src/uwx/records.js +19 -16
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +322 -38
- package/src/uwx/site.js +146 -35
package/src/uwx/site.js
CHANGED
|
@@ -56,8 +56,7 @@ import {
|
|
|
56
56
|
isIgnoredFolder,
|
|
57
57
|
parseNumericPrefix,
|
|
58
58
|
compareFilenames,
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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).
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|