@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/package.json +8 -8
- package/src/i18n/extract.js +24 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/index.js +5 -32
- package/src/i18n/locales.js +51 -0
- 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/index.js +3 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/project-writer.js +106 -31
- package/src/uwx/record-identity.js +46 -0
- package/src/uwx/records-project.js +109 -22
- 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 +151 -36
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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).
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|