@uniweb/build 0.63.4 → 0.64.1
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 +7 -7
- package/src/i18n/extract.js +43 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +36 -17
- package/src/i18n/records.js +393 -161
- package/src/runtime-schema.js +3 -1
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/queries-config.js +8 -1
- package/src/site/query-processor.js +13 -2
- package/src/site/registered-foundation.js +47 -0
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +20 -9
- package/src/uwx/data-schema.js +34 -0
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +9 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/open-graph.js +69 -0
- package/src/uwx/project-writer.js +109 -31
- package/src/uwx/records-project.js +156 -28
- package/src/uwx/records.js +67 -18
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +334 -39
- package/src/uwx/site.js +178 -44
- package/src/uwx/sync-package.js +24 -1
- package/src/validate-data.js +15 -5
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,8 @@ 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, SITE_META_CONTEXT } from '../i18n/extract.js'
|
|
80
|
+
import { splitOpenGraph, openGraphValue, seoBesideOpenGraph } from './open-graph.js'
|
|
78
81
|
import { updateBackendState, readBackendState } from './sync-store.js'
|
|
79
82
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
80
83
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
@@ -142,6 +145,89 @@ function stripCredentials(block, label) {
|
|
|
142
145
|
// processMarkdownFile only destructures type/component/preset/input/props/
|
|
143
146
|
// fetch/data/id out of frontmatter, so `background:` and `theme:` stay
|
|
144
147
|
// inside section.params — lift them into the entity type's dedicated fields.
|
|
148
|
+
// The `type` a push sends for a section whose file names none — the Model requires one. A pull does
|
|
149
|
+
// not write it back into a file that names none (`sectionRecordToFile`): absent, the foundation's
|
|
150
|
+
// `defaultSection` renders it, which is not always `Content`.
|
|
151
|
+
export const FILLED_SECTION_TYPE = 'Content'
|
|
152
|
+
|
|
153
|
+
const AUTHORED_SLUG = Symbol('authoredSlug')
|
|
154
|
+
const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Post-pass: a page's localized URL segments ride in its localized `slug`, beside the source one —
|
|
158
|
+
* the slot the site-content Model declares for them. From the form the site keeps them in, read as
|
|
159
|
+
* the build reads it (`buildRouteTranslations`): the pages' own `slug: { <locale>: <segment> }` when
|
|
160
|
+
* any page declares one, else `site.yml`'s `i18n.routeTranslations`, a whole route per locale, taken
|
|
161
|
+
* apart into each page's segment. ⛔ Until 2026-09-26 only the source slug was sent, so a clone lost
|
|
162
|
+
* every localized URL — measured on the `international` template, whose `/acerca-de` came back as
|
|
163
|
+
* `/about`.
|
|
164
|
+
*
|
|
165
|
+
* A route whose localized form is not its parent's plus one segment cannot be said per page, and is
|
|
166
|
+
* named rather than dropped in silence. Mutates the records in place.
|
|
167
|
+
*/
|
|
168
|
+
function localizeRouteSlugs(pages, siteYml, sourceLocale) {
|
|
169
|
+
const languages = Array.isArray(siteYml?.languages) ? new Set(siteYml.languages) : null
|
|
170
|
+
const carried = (locale, segment) =>
|
|
171
|
+
locale !== sourceLocale && (!languages || languages.has(locale)) && isValidSlugSegment(segment)
|
|
172
|
+
// Each page with the route the build gives it (`buildRouteOf`).
|
|
173
|
+
const visit = (records, parentRoute, fn) => {
|
|
174
|
+
for (const record of records || []) {
|
|
175
|
+
const route = buildRouteOf(record, parentRoute, sourceLocale)
|
|
176
|
+
fn(record, route, parentRoute)
|
|
177
|
+
visit(record.$children, route, fn)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let ownForm = false
|
|
181
|
+
visit(pages, '/', (record) => { if (record[AUTHORED_SLUG]) ownForm = true })
|
|
182
|
+
if (ownForm) {
|
|
183
|
+
visit(pages, '/', (record) => {
|
|
184
|
+
for (const [locale, segment] of Object.entries(record[AUTHORED_SLUG] || {})) {
|
|
185
|
+
if (carried(locale, segment)) record.slug[locale] = segment
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
const map = siteYml?.i18n?.routeTranslations
|
|
191
|
+
if (!isPlainRecord(map)) return
|
|
192
|
+
const segments = new Map() // canonical route → { locale: segment }, as the build composes them
|
|
193
|
+
visit(pages, '/', (record, route, parentRoute) => {
|
|
194
|
+
if (record.is_dynamic || record.is_index) return
|
|
195
|
+
for (const [locale, table] of Object.entries(map)) {
|
|
196
|
+
const display = isPlainRecord(table) ? table[route] : undefined
|
|
197
|
+
if (typeof display !== 'string') continue
|
|
198
|
+
const parts = display.split('/').filter(Boolean)
|
|
199
|
+
const segment = parts.pop()
|
|
200
|
+
if (`/${parts.join('/')}` !== composeLocalizedRoute(parentRoute, locale, segments)) {
|
|
201
|
+
console.warn(
|
|
202
|
+
`uwx/site: \`i18n.routeTranslations\` gives ${route} the ${locale} URL ${display}, which is not ` +
|
|
203
|
+
`its parent's plus one segment — it is not sent. Give the page \`slug: { ${locale}: … }\` instead.`
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (!carried(locale, segment)) continue
|
|
208
|
+
record.slug[locale] = segment
|
|
209
|
+
segments.set(route, { ...(segments.get(route) || {}), [locale]: segment })
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The route the build gives a page record: an index page takes its parent's, a parametric one is
|
|
216
|
+
* `:<param>` (`[...path]` is `:path*`), any other its slug under its parent. One rule, exported for the
|
|
217
|
+
* pull (`site-project.js`), which must address a page as the push does.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} record - a page record (`slug`, `is_index`, `is_dynamic`)
|
|
220
|
+
* @param {string} parentRoute - its parent's route, `/` at the top
|
|
221
|
+
* @param {string} sourceLocale
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function buildRouteOf(record, parentRoute, sourceLocale) {
|
|
225
|
+
if (record?.is_index) return parentRoute
|
|
226
|
+
const slug = unwrapLocalized(record?.slug, sourceLocale)
|
|
227
|
+
const segment = record?.is_dynamic ? (slug === CATCH_ALL_MARKER ? ':path*' : `:${slug}`) : slug
|
|
228
|
+
return parentRoute === '/' ? `/${segment}` : `${parentRoute}/${segment}`
|
|
229
|
+
}
|
|
230
|
+
|
|
145
231
|
// Post-pass: wrap every section's `content` (page sections + their `$children`,
|
|
146
232
|
// recursing into child pages, plus layout sections) into per-locale form. Mutates
|
|
147
233
|
// the records in place. Only called for multi-locale sites. Each target locale is
|
|
@@ -154,9 +240,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
|
|
|
154
240
|
|
|
155
241
|
const localizeSection = async (record, page) => {
|
|
156
242
|
if (!record.content) return
|
|
157
|
-
|
|
243
|
+
const stableId = record.stable_id || record.$id
|
|
244
|
+
// Resolved in the section's context, as the build resolves it (`translationContext`): a
|
|
245
|
+
// context-specific override applies here and nowhere else. ⛔ Until 2026-09-26 every section was
|
|
246
|
+
// resolved with none, so an override never reached a backend.
|
|
247
|
+
let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route))
|
|
158
248
|
if (page) {
|
|
159
|
-
const section = { stableId
|
|
249
|
+
const section = { stableId }
|
|
160
250
|
for (const locale of targetLocales) {
|
|
161
251
|
// loadFreeformTranslation returns { content, frontmatter, … } — the doc is `.content`.
|
|
162
252
|
const body = (await loadFreeformTranslation(section, page, locale, freeformBase))?.content
|
|
@@ -176,19 +266,38 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
|
|
|
176
266
|
if (Array.isArray(s.$children)) await visitSections(s.$children, page)
|
|
177
267
|
}
|
|
178
268
|
}
|
|
179
|
-
|
|
269
|
+
// A page is addressed by the route the build gives it (`buildRouteOf`): a free-form translation is
|
|
270
|
+
// looked up by it, and a translation's context names it. ⛔ Until 2026-09-26 this was the slug path,
|
|
271
|
+
// so a homepage was `home` where the build reads `/`, and its sections' free-form files were never sent.
|
|
272
|
+
const visitPages = async (pgs, parentRoute) => {
|
|
180
273
|
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
|
|
274
|
+
const route = buildRouteOf(p, parentRoute, sourceLocale)
|
|
185
275
|
const page = { route, id: p.stable_id }
|
|
276
|
+
localizePageMeta(p, route, sourceLocale, translations)
|
|
186
277
|
if (Array.isArray(p.page_sections)) await visitSections(p.page_sections, page)
|
|
187
278
|
if (Array.isArray(p.$children)) await visitPages(p.$children, route)
|
|
188
279
|
}
|
|
189
280
|
}
|
|
190
|
-
await visitPages(pages, '')
|
|
191
|
-
|
|
281
|
+
await visitPages(pages, '/')
|
|
282
|
+
// A layout area's section has its free-form home at the area's route, as the build reads it
|
|
283
|
+
// (`layoutAreaRoute`). ⛔ This said "layout sections have no free-form home" until 2026-09-26.
|
|
284
|
+
for (const s of layoutSections || []) await localizeSection(s, { route: layoutAreaRoute(s.layout_name, s.area || s.stable_id) })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ⭐ A page's title, label, description and keywords are resolved in the page's context, as the
|
|
288
|
+
// build translates them (`i18n/merge.js::translatePageMeta`, `<route>:_meta`): an override for one
|
|
289
|
+
// page applies there and nowhere else. ⛔ Until 2026-09-26 they were resolved with no page, so an
|
|
290
|
+
// override never reached a backend — nor, until the same day, an entry's default.
|
|
291
|
+
function localizePageMeta(page, route, sourceLocale, translations) {
|
|
292
|
+
const context = { page: route, section: '_meta' }
|
|
293
|
+
const again = (value) =>
|
|
294
|
+
value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
|
|
295
|
+
? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
|
|
296
|
+
: value
|
|
297
|
+
for (const key of ['title', 'label', 'description', 'og_title', 'og_description']) {
|
|
298
|
+
if (page[key] !== undefined) page[key] = again(page[key])
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(page.keywords)) page.keywords = page.keywords.map(again)
|
|
192
301
|
}
|
|
193
302
|
|
|
194
303
|
function mapSectionData(section) {
|
|
@@ -198,7 +307,7 @@ function mapSectionData(section) {
|
|
|
198
307
|
delete params.background
|
|
199
308
|
delete params.theme
|
|
200
309
|
|
|
201
|
-
const data = { type: section.type ||
|
|
310
|
+
const data = { type: section.type || FILLED_SECTION_TYPE } // entity type requires `type`
|
|
202
311
|
setIf(data, 'stable_id', section.stableId ?? undefined)
|
|
203
312
|
setIf(data, 'preset', section.preset ?? undefined)
|
|
204
313
|
setIf(data, 'input', section.input ?? undefined)
|
|
@@ -212,13 +321,13 @@ function mapSectionData(section) {
|
|
|
212
321
|
}
|
|
213
322
|
|
|
214
323
|
function buildPageData(config, ctx) {
|
|
215
|
-
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
|
|
324
|
+
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where, pageFields } =
|
|
216
325
|
ctx
|
|
217
326
|
// 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
|
-
//
|
|
327
|
+
// site-content Model declares it localized; greenlit 2026-06-13). The source
|
|
328
|
+
// entry is the folder name; the other locales' segments are added by
|
|
329
|
+
// `localizeRouteSlugs`, once the whole tree is known. `mode` is the plain
|
|
330
|
+
// delivery mode.
|
|
222
331
|
const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
|
|
223
332
|
setIf(data, 'stable_id', config.id)
|
|
224
333
|
setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
|
|
@@ -249,7 +358,13 @@ function buildPageData(config, ctx) {
|
|
|
249
358
|
setIf(data, 'redirect', config.redirect)
|
|
250
359
|
setIf(data, 'rewrite', config.rewrite)
|
|
251
360
|
setIf(data, 'layout', config.layout)
|
|
252
|
-
|
|
361
|
+
// ⭐ Its Open Graph title and description per language, where the deployment's page takes them
|
|
362
|
+
// (`uwx/open-graph.js`) — out of `seo`, which travels as one object. ⛔ Until 2026-09-26 they rode
|
|
363
|
+
// inside `seo` only, in the source language, though the build translates them.
|
|
364
|
+
const og = splitOpenGraph(config.seo, pageFields, sourceLocale, translations)
|
|
365
|
+
setIf(data, 'og_title', og.og_title)
|
|
366
|
+
setIf(data, 'og_description', og.og_description)
|
|
367
|
+
setIf(data, 'seo', og.seo)
|
|
253
368
|
// ⭐ A `query:` or `fetch:` LIST means "fetch each" — one declaration per entry. Before
|
|
254
369
|
// 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
|
|
255
370
|
// carried one dataset for a page that asked for several.
|
|
@@ -351,21 +466,14 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
|
|
|
351
466
|
order: typeof config.order === 'number' ? config.order : undefined,
|
|
352
467
|
})
|
|
353
468
|
}
|
|
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
|
|
469
|
+
// Explicit order, then numeric-prefix filename order, then the parent's `pages:` wildcard —
|
|
470
|
+
// one function, which the pull reproduces a level's order with too (`site-project.js`).
|
|
471
|
+
// ⚠️ It does NOT match the build's own page-folder sort in one case, and the note that said
|
|
472
|
+
// "mirror content-collector's pageFolders sort" here read as if it did: the collector compares
|
|
473
|
+
// a level's folder names whole, as strings, and matches `pages:` against them whole, where
|
|
474
|
+
// this strips a numeric prefix (`2-about` is `about`, and `10-` sorts after `2-`). Only a level
|
|
475
|
+
// with numbered page folders can tell them apart, and which rule is right for them is open.
|
|
476
|
+
return orderFolders(folders, parentConfig?.pages)
|
|
369
477
|
}
|
|
370
478
|
|
|
371
479
|
const DYNAMIC_RE = /^\[(.+)\]$/
|
|
@@ -630,7 +738,7 @@ const REPUBLISH_CLAUSE =
|
|
|
630
738
|
// Recursively build the `pages` tree: each record carries its fields, its inline
|
|
631
739
|
// `page_sections` (page mode only), and its child pages under `$children`.
|
|
632
740
|
async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
|
|
633
|
-
const { siteRoot, siteIndex, sourceLocale, translations } = ctx
|
|
741
|
+
const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
|
|
634
742
|
const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
|
|
635
743
|
const out = []
|
|
636
744
|
// A folder inside a `[...path]` folder can never be reached, and `[dir]` /
|
|
@@ -664,11 +772,15 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
664
772
|
siteIndex,
|
|
665
773
|
sourceLocale,
|
|
666
774
|
translations,
|
|
775
|
+
pageFields,
|
|
667
776
|
})
|
|
668
777
|
// `$id` is the stableId when authored (rename-stable), else the slug (the
|
|
669
778
|
// natural handle — spec default). The path is NEVER the identity.
|
|
670
779
|
const id = data.stable_id || slug
|
|
671
780
|
const record = withIdentity(id, data)
|
|
781
|
+
// A page's own localized URL segments, for `localizeRouteSlugs` — under a symbol, so they
|
|
782
|
+
// are never serialized as a field of the record.
|
|
783
|
+
if (!dyn && isPlainRecord(f.config?.slug)) record[AUTHORED_SLUG] = f.config.slug
|
|
672
784
|
|
|
673
785
|
if (mode === 'page') {
|
|
674
786
|
const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
|
|
@@ -878,7 +990,9 @@ const DECL_EMITTED_ABOVE = new Set([
|
|
|
878
990
|
'limit',
|
|
879
991
|
'excerpt',
|
|
880
992
|
'deferred',
|
|
881
|
-
'queryable'
|
|
993
|
+
'queryable',
|
|
994
|
+
// Derived by the rule above, never taken from a file.
|
|
995
|
+
'typed_by_data_key',
|
|
882
996
|
])
|
|
883
997
|
|
|
884
998
|
const DECL_NOT_ON_WIRE = new Set([
|
|
@@ -920,7 +1034,7 @@ function externalSource(d) {
|
|
|
920
1034
|
return source
|
|
921
1035
|
}
|
|
922
1036
|
|
|
923
|
-
function queriesNested(declarations, uuids = null, scope = null, keyTyped = null) {
|
|
1037
|
+
function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null) {
|
|
924
1038
|
const out = []
|
|
925
1039
|
for (const [name, d] of Object.entries(declarations)) {
|
|
926
1040
|
refuseUnder(d.where, `queries.${name}`)
|
|
@@ -939,8 +1053,13 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
939
1053
|
// empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
|
|
940
1054
|
// A query whose name-defaulted schema stands for the type of the data key of its name
|
|
941
1055
|
// names that type — the data schema its records are sent as (`data-key-types.js`).
|
|
942
|
-
const
|
|
1056
|
+
const typed = (!d.schemaExplicit && keyTyped?.get(d.schema)) || null
|
|
1057
|
+
const schema = typed || d.schema
|
|
943
1058
|
setIf(data, 'schema', resolveSelfScope(schema, scope))
|
|
1059
|
+
// ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
|
|
1060
|
+
// one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
|
|
1061
|
+
// → `siteContent.queryFields`), since one that does not refuses a push carrying it.
|
|
1062
|
+
if (typed && Array.isArray(queryFields) && queryFields.includes('typed_by_data_key')) data.typed_by_data_key = true
|
|
944
1063
|
setIf(data, 'sort', d.sort)
|
|
945
1064
|
// Legacy `filter:` is not synced — it is translated to `where` upstream
|
|
946
1065
|
// (the canonical predicate). No legacy fields on the wire.
|
|
@@ -1136,7 +1255,7 @@ function secretsNested(provisioned) {
|
|
|
1136
1255
|
// ROUND-TRIP LAW). `site-project.js::SETTINGS_TO_SITE_YML` plus its explicit
|
|
1137
1256
|
// branches is the other half, and `producer-list-drift.test.js` fails if a key
|
|
1138
1257
|
// emitted here has neither.
|
|
1139
|
-
function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations } = {}) {
|
|
1258
|
+
function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages = null, settingsFields = null } = {}) {
|
|
1140
1259
|
const settings = {}
|
|
1141
1260
|
|
|
1142
1261
|
// Site-wide values an author declares once and references from page content as
|
|
@@ -1170,7 +1289,13 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1170
1289
|
// ⇒ `settings` is where they live. `publish_languages` rides VERBATIM, dangling
|
|
1171
1290
|
// codes included: sync carries the full working set and only *publish* filters,
|
|
1172
1291
|
// which preserves a locale's publish intent across a remove + re-add in `languages:`.
|
|
1173
|
-
|
|
1292
|
+
// ⭐ A site that declares no `languages:` has the languages its translation files make — the build's
|
|
1293
|
+
// rule (`i18n/locales.js`) — and sends that list (`languages`, below), as it sends a derived
|
|
1294
|
+
// `deferred:`: a host may serve a site in the languages it declares and no others, and the site's
|
|
1295
|
+
// localized URLs with them. The pull does not write it back where the files already say it
|
|
1296
|
+
// (`siteContentDocumentToProject`). ⛔ Until 2026-09-26 only a declared list was sent, so the
|
|
1297
|
+
// `international` template, which declares none, sent no language but its source.
|
|
1298
|
+
setIf(settings, 'languages', siteYml.languages ?? languages ?? undefined)
|
|
1174
1299
|
setIf(settings, 'default_language', siteYml.defaultLanguage)
|
|
1175
1300
|
setIf(settings, 'publish_languages', siteYml.publishLanguages)
|
|
1176
1301
|
|
|
@@ -1179,7 +1304,12 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1179
1304
|
// (`core/src/seo.js`). Two of those are literally sitemap.xml columns. It only
|
|
1180
1305
|
// ever passed the card test because `image` was inside it; the card's picture is
|
|
1181
1306
|
// `info.preview` now.
|
|
1182
|
-
|
|
1307
|
+
// The site's Open Graph title and description per language, where the deployment's `settings`
|
|
1308
|
+
// takes them (`uwx/open-graph.js`), in the site's context (`SITE_META_CONTEXT`).
|
|
1309
|
+
// (Each read inline from `siteYml.seo`, so the emit surface names its source.)
|
|
1310
|
+
setIf(settings, 'og_title', openGraphValue(siteYml.seo, 'og_title', settingsFields, sourceLocale, translations, SITE_META_CONTEXT))
|
|
1311
|
+
setIf(settings, 'og_description', openGraphValue(siteYml.seo, 'og_description', settingsFields, sourceLocale, translations, SITE_META_CONTEXT))
|
|
1312
|
+
setIf(settings, 'seo', seoBesideOpenGraph(siteYml.seo, settingsFields))
|
|
1183
1313
|
// ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
|
|
1184
1314
|
// (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
|
|
1185
1315
|
// convenience, not because it is a different kind of thing. Localized, so it
|
|
@@ -1461,7 +1591,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1461
1591
|
// (uwx-format.md → info.url.)
|
|
1462
1592
|
|
|
1463
1593
|
// `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
|
|
1464
|
-
|
|
1594
|
+
// …and the keys this deployment's page takes (`siteContent.pageFields`), for its Open Graph texts.
|
|
1595
|
+
const ctx = { siteRoot, sourceLocale, translations, pageFields: opts.pageFields }
|
|
1465
1596
|
// ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
|
|
1466
1597
|
// The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
|
|
1467
1598
|
// `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
|
|
@@ -1506,6 +1637,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1506
1637
|
// — as the build does — pushed no homepage at all.
|
|
1507
1638
|
const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
|
|
1508
1639
|
pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
|
|
1640
|
+
localizeRouteSlugs(pages, siteYml, sourceLocale)
|
|
1509
1641
|
}
|
|
1510
1642
|
|
|
1511
1643
|
const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
|
|
@@ -1539,7 +1671,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1539
1671
|
doc.$schema = SITE_MODEL_NAME
|
|
1540
1672
|
doc.info = info
|
|
1541
1673
|
// Emitted only when the file declares something — see `settingsNested`.
|
|
1542
|
-
|
|
1674
|
+
// The languages the site's translation files make, for a site that declares none (`settingsNested`).
|
|
1675
|
+
const languages = targetLocales.length > 0 ? [sourceLocale, ...targetLocales] : null
|
|
1676
|
+
const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages, settingsFields: opts.settingsFields })
|
|
1543
1677
|
if (settings) doc.settings = settings
|
|
1544
1678
|
doc.pages = pages
|
|
1545
1679
|
doc.layout_sections = layoutSections
|
|
@@ -1552,7 +1686,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1552
1686
|
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1553
1687
|
// consumer sees, leave the identifier alone.
|
|
1554
1688
|
const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
|
|
1555
|
-
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped)
|
|
1689
|
+
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped, opts.queryFields)
|
|
1556
1690
|
// Emitted ONLY when the file declares the key — see the header above
|
|
1557
1691
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1558
1692
|
// requests and one of them is destructive.
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -359,6 +359,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
359
359
|
// …and the same data schema, where a name stands for the type of its data key
|
|
360
360
|
// (`@/team` → `@/member`, `data-key-types.js`): decided once, by the records.
|
|
361
361
|
keyTyped: col.keyTyped,
|
|
362
|
+
// …and whether this deployment takes the key that says so (`siteContent.queryFields`) —
|
|
363
|
+
// and the page and `settings` keys that carry Open Graph texts per language.
|
|
364
|
+
...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
|
|
365
|
+
...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
|
|
366
|
+
...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
|
|
362
367
|
// Withhold the `$services`/`$secrets` Sections when the caller has
|
|
363
368
|
// determined the file is not asking for anything new by them. Passed
|
|
364
369
|
// through rather than decided here: the last-agreed state is project
|
|
@@ -517,6 +522,21 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
517
522
|
}))
|
|
518
523
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
519
524
|
|
|
525
|
+
// ⛔ A RECORD THAT LOST ITS IDENTITY IS NOT UNCHANGED. Its hash was banked for this backend,
|
|
526
|
+
// so a push to it succeeded — and wrote the record's `$uuid` into its file — yet it goes with no
|
|
527
|
+
// `$uuid` now: the line is gone from the file. Skipped as unchanged, the folder still named it,
|
|
528
|
+
// by `$ref`, and the backend refused a reference to a record the package does not carry
|
|
529
|
+
// (measured 2026-09-26); sent, it would be a second record. A pull writes it back.
|
|
530
|
+
const refusals = [...(col.refusals || [])]
|
|
531
|
+
for (const r of recordChanged) {
|
|
532
|
+
if (r.changed || r.entity.document.$uuid) continue
|
|
533
|
+
refusals.push(
|
|
534
|
+
`${r.entity.id}: its \`$uuid\` is gone from its file, though it was pushed to this backend ` +
|
|
535
|
+
'before — sent as it is, it would be a second record. Run `uniweb pull`, which writes it ' +
|
|
536
|
+
'back, then push.'
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
520
540
|
let records = null
|
|
521
541
|
// ⭐ A RECORD THAT NAMES A RECORD THIS PUSH CREATES names it by `$ref`
|
|
522
542
|
// (`records.js::encodeReference`), and the backend stores the uuid it mints for it. The
|
|
@@ -572,6 +592,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
572
592
|
// banked is then, by construction, what was hashed. A future injection is covered
|
|
573
593
|
// by adding one line here — not by finding every reader.
|
|
574
594
|
const applied = {
|
|
595
|
+
...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
|
|
596
|
+
...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
|
|
597
|
+
...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
|
|
575
598
|
...(assetRewrite ? { assetRewrite } : {}),
|
|
576
599
|
...(assetIds ? { assetIds } : {}),
|
|
577
600
|
...(injectInfo ? { injectInfo } : {}),
|
|
@@ -582,7 +605,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
582
605
|
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
583
606
|
// Records that name a record this push creates — see `namesNew` above.
|
|
584
607
|
namesNew,
|
|
585
|
-
refusals
|
|
608
|
+
refusals,
|
|
586
609
|
schemaless: col.schemaless, localAssets, applied,
|
|
587
610
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
|
588
611
|
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
package/src/validate-data.js
CHANGED
|
@@ -206,10 +206,19 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
206
206
|
// (pass 2). A list root was deferred here until then, though the query's records
|
|
207
207
|
// are exactly that list.
|
|
208
208
|
|
|
209
|
+
// ⭐ WHAT THE QUERY'S RECORDS ARE, for a key typed by a schema whose root is a list: the
|
|
210
|
+
// list's ITEMS when the query selects other records (`@/link` records feeding `@/menu`), but
|
|
211
|
+
// ENTITIES of the list schema — each holding a whole list — when the query names that schema
|
|
212
|
+
// itself (`menus: { schema: '@/menu' }`). ⛔ Until 2026-09-26 both were checked as items, so
|
|
213
|
+
// every query over a list schema failed ("missing required field '[0].label'") and a push,
|
|
214
|
+
// which gates on this check, stopped.
|
|
215
|
+
const decl = typeof input.query === 'string' ? config.queries?.[input.query] : null
|
|
216
|
+
const querySchema = decl ? decl.schema ?? keyTypes.get(input.query) ?? `@/${input.query}` : undefined
|
|
217
|
+
|
|
209
218
|
const pairKey = `${input.path} ${ref}`
|
|
210
219
|
let entry = work.get(pairKey)
|
|
211
220
|
if (!entry) {
|
|
212
|
-
entry = { path: input.path, ref, schema, users: [] }
|
|
221
|
+
entry = { path: input.path, ref, schema, users: [], entities: querySchema === ref }
|
|
213
222
|
work.set(pairKey, entry)
|
|
214
223
|
}
|
|
215
224
|
entry.users.push({ route: page.route, section: type, key })
|
|
@@ -234,10 +243,11 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
234
243
|
schemasSeen.add(entry.ref)
|
|
235
244
|
const items = Array.isArray(records) ? records : [records]
|
|
236
245
|
|
|
237
|
-
// A schema whose ROOT IS A LIST describes the whole value the key receives, and
|
|
238
|
-
//
|
|
239
|
-
// schema describes one record, checked per record
|
|
240
|
-
|
|
246
|
+
// A schema whose ROOT IS A LIST describes the whole value the key receives, and a query of
|
|
247
|
+
// other records delivers that list — so they are checked once, as the list. Any other
|
|
248
|
+
// schema describes one record, checked per record — and so are the entities of a list
|
|
249
|
+
// schema a query of that schema delivers, each holding its list (`validateItem`).
|
|
250
|
+
if (rootListSection(entry.schema) && !entry.entities) {
|
|
241
251
|
recordCount += items.length
|
|
242
252
|
const base = { file: entry.path, schema: entry.ref, users: entry.users }
|
|
243
253
|
for (const finding of validateBound(entry.schema, items)) {
|