@uniweb/build 0.74.4 → 0.75.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/src/schema.js CHANGED
@@ -18,6 +18,7 @@ import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
19
  import { describeChildren, describeVisuals, lowerChildren } from '@uniweb/schemas/component'
20
20
  import { describeContent, lowerData } from '@uniweb/schemas/content'
21
+ import { normalizeData } from '@uniweb/schemas/foundation'
21
22
  import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
22
23
  import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
23
24
  import {
@@ -298,7 +299,6 @@ function retiredScopeMessage(pkgPath, scope, name) {
298
299
  * Contains foundation-wide configuration:
299
300
  * - vars: CSS custom properties sites can override
300
301
  * - defaultLayout: Default layout name
301
- * - props: Foundation-wide props
302
302
  * - Future: providers, middleware, etc.
303
303
  */
304
304
  /**
@@ -315,7 +315,6 @@ const DEFAULT_ONLY_CAPABILITIES = [
315
315
  'defaultLayout',
316
316
  'defaultSection',
317
317
  'viewTransitions',
318
- 'props',
319
318
  'defaultInsets',
320
319
  'xref',
321
320
  'outputs',
@@ -328,8 +327,9 @@ const DEFAULT_ONLY_CAPABILITIES = [
328
327
  /**
329
328
  * Warn when a capability was written as a named export.
330
329
  *
331
- * `generate-entry.js` builds `capabilities` as `{ ..._foundationModule.default,
332
- * vars: … }` — it spreads only the DEFAULT export. A named `export const xref =
330
+ * `generate-entry.js` builds `capabilities` from the DEFAULT export only — the code the
331
+ * runtime calls, referenced, and its data, lean (`extractFoundationRuntime`; until
332
+ * 2026-10-01 it spread the whole default export). A named `export const xref =
333
333
  * …` is therefore dropped with no error, no warning, and a build that succeeds.
334
334
  * Whatever the capability did simply does not happen, which reads as "the
335
335
  * feature is not implemented" rather than "it is declared in the wrong place".
@@ -474,10 +474,20 @@ export async function loadFoundationConfig(srcDir) {
474
474
  if (existsSync(legacy)) {
475
475
  throw new Error(
476
476
  `${legacy} is no longer read — rename it to main.js.\n` +
477
- ` A foundation's declarations (vars, defaultLayout, defaultSection, props, name) live in\n` +
477
+ ` A foundation's declarations (vars, defaultLayout, defaultSection, name) live in\n` +
478
478
  ` main.js. Left as foundation.js, the build would continue with none of them.`,
479
479
  )
480
480
  }
481
+ // ⛔ The same for `main.jsx`, which the entry used to import while this read nothing — the
482
+ // editor saw none of its declarations, the runtime all of them (until 2026-10-01). A main.js
483
+ // may hold JSX: `importFoundationConfig` transpiles it.
484
+ const jsx = join(srcDir, 'main.jsx')
485
+ if (existsSync(jsx)) {
486
+ throw new Error(
487
+ `${jsx} is not read — rename it to main.js, which may hold JSX.\n` +
488
+ ` A foundation's declarations live in main.js; left as main.jsx, the build would continue with none of them.`,
489
+ )
490
+ }
481
491
  return {}
482
492
  }
483
493
 
@@ -612,8 +622,12 @@ function buildComponentEntry(name, relativePath, meta) {
612
622
  path: relativePath,
613
623
  }
614
624
 
625
+ // ⭐ `[]` says the component TAKES NO CONTENT — `content: {}` or `content: []` in its `meta.js`
626
+ // [Diego, 2026-10-01] — a component that only reads `content.data`, say. Absent says it
627
+ // declared nothing, which is unknown. ⛔ Until 2026-10-01 both came out absent.
615
628
  const content = describeContent(meta)
616
629
  if (content.elements.length > 0) entry.content = content.elements
630
+ else if (declaresNoContent(meta.content)) entry.content = []
617
631
  else delete entry.content
618
632
 
619
633
  const children = lowerChildren(meta)
@@ -627,6 +641,12 @@ function buildComponentEntry(name, relativePath, meta) {
627
641
  return entry
628
642
  }
629
643
 
644
+ /** `content: {}` or `content: []` — the component's way of saying it takes no content. */
645
+ function declaresNoContent(content) {
646
+ if (Array.isArray(content)) return content.length === 0
647
+ return content !== null && typeof content === 'object' && Object.keys(content).length === 0
648
+ }
649
+
630
650
  /**
631
651
  * Refuse a declaration that was retired, naming what replaces it. A refusal, not a
632
652
  * warning: a retired key would otherwise register as if it still meant something.
@@ -1010,6 +1030,31 @@ function reportSupports(srcDir, authored, derived, emitted) {
1010
1030
  }
1011
1031
  }
1012
1032
 
1033
+ /**
1034
+ * Warn of a `data:` value `register` will refuse — one the normalized form it sends cannot
1035
+ * write (`@uniweb/schemas/foundation`): an inline field map that is not a valid data schema.
1036
+ * The build goes on; the site renders the map as before. `register` is where it stops, since
1037
+ * a registered schema is written in one spelling per meaning or not at all.
1038
+ *
1039
+ * @param {Object} components - discovered section types, by name
1040
+ * @param {Object} [layouts] - discovered layouts, by name
1041
+ * @param {Object} [foundationConfig] - the foundation's `main.js`, whose `data:` reaches every section
1042
+ */
1043
+ export function reportUnregistrableData(components, layouts = {}, foundationConfig = {}) {
1044
+ const owners = [
1045
+ ...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
1046
+ ...Object.entries(layouts || {}).map(([name, entry]) => [`layout ${name} (meta.js)`, entry?.data]),
1047
+ ['the foundation (main.js)', foundationConfig?.data],
1048
+ ]
1049
+ for (const [owner, data] of owners) {
1050
+ try {
1051
+ normalizeData(data, { owner })
1052
+ } catch (err) {
1053
+ console.warn(`Warning: ${err.message} \`uniweb register\` refuses it until it is one.`)
1054
+ }
1055
+ }
1056
+ }
1057
+
1013
1058
  /**
1014
1059
  * Warn about `content` and `children` declarations an editor cannot use — what lowering
1015
1060
  * them could not read (`@uniweb/schemas`, the grammar the editor reads them with). The
@@ -1109,6 +1154,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1109
1154
 
1110
1155
  // Discover layouts from src/layouts/
1111
1156
  const layouts = await discoverLayoutsInPath(srcDir)
1157
+ reportUnregistrableData(components, layouts, foundationConfig)
1112
1158
 
1113
1159
  // Determine extension role
1114
1160
  const isExtension = !!foundationConfig.extension
@@ -36,6 +36,7 @@ import { checkFoundationResolution } from '../utils/foundation-resolution-check.
36
36
  import { detectFoundationType } from './foundation-ref.js'
37
37
  import { readPreview, PREVIEW_ENV } from './preview.js'
38
38
  import { previewMediaPlugin } from './preview-media.js'
39
+ import { previewDirectoryIndexPlugin } from './preview-directory-index.js'
39
40
 
40
41
  /**
41
42
  * Normalize a base path for Vite compatibility
@@ -396,6 +397,9 @@ export async function defineSiteConfig(options = {}) {
396
397
 
397
398
  if (noopFoundationPlugin) plugins.push(noopFoundationPlugin)
398
399
 
400
+ // `vite preview` answers `/about` with `about/index.html`, as the hosts a build deploys to do.
401
+ plugins.push(previewDirectoryIndexPlugin())
402
+
399
403
  // Import map plugin for runtime mode production builds.
400
404
  // Emits re-export modules for each externalized package (react, @uniweb/core, etc.)
401
405
  // so the browser can resolve bare specifiers in the dynamically-imported foundation.
@@ -251,6 +251,7 @@ async function processDevSectionFetches(sections, fetchOptions, resolve) {
251
251
  }
252
252
  import { generateSearchIndex, getSearchIndexFilename } from '@uniweb/projections'
253
253
  import { mergeTranslations } from '../i18n/merge.js'
254
+ import { dataBlockModels, NO_DATA_MODELS } from '../i18n/data-models.js'
254
255
  import { searchDeclaredOn } from './search-declared.js'
255
256
  import { mountDevApi } from '../dev/api-mount.js'
256
257
 
@@ -654,6 +655,9 @@ export function siteContentPlugin(options = {}) {
654
655
  }
655
656
 
656
657
  let siteContent = null
658
+ // What each section type declares its data blocks are — read once per collection of the site.
659
+ let dataModelPromise = null
660
+ const dataModels = () => (dataModelPromise ||= dataBlockModels(resolvedSitePath).catch(() => NO_DATA_MODELS))
657
661
  let resolvedSitePath = null
658
662
  let resolvedPublicDir = null
659
663
  let resolvedOutDir = null
@@ -791,7 +795,8 @@ export function siteContentPlugin(options = {}) {
791
795
  return mergeTranslations(siteContent, translations || {}, {
792
796
  locale,
793
797
  localesDir: join(resolvedSitePath, localesDir),
794
- freeformEnabled: hasFreeform
798
+ freeformEnabled: hasFreeform,
799
+ dataModel: await dataModels()
795
800
  })
796
801
  }
797
802
 
@@ -951,6 +956,7 @@ export function siteContentPlugin(options = {}) {
951
956
  // strict on a production build: a mount that contributes no pages is a
952
957
  // warning while you author and a broken deploy once you ship.
953
958
  siteContent = await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })
959
+ dataModelPromise = null
954
960
  headHtml = await loadHeadHtml()
955
961
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
956
962
 
@@ -1020,6 +1026,7 @@ export function siteContentPlugin(options = {}) {
1020
1026
  console.log('[site-content] Content changed, rebuilding...')
1021
1027
  try {
1022
1028
  siteContent = await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })
1029
+ dataModelPromise = null
1023
1030
  headHtml = await loadHeadHtml()
1024
1031
  if (materialize) {
1025
1032
  queriesConfig = siteContent.config?.queries
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ⭐ `vite preview` SERVES A PAGE'S FOLDER FOR ITS URL WITHOUT THE SLASH — as the hosts a site
3
+ * deploys to do. A build writes each page as `<route>/index.html`, and GitHub Pages, Cloudflare
4
+ * Pages, Netlify and Vercel answer `/fr/a-propos` with `fr/a-propos/index.html` (the S3 + CloudFront
5
+ * adapter ships a function for it, `../hosts/s3-cloudfront.js`).
6
+ *
7
+ * `vite preview` answers it with the site's ROOT document instead: its SPA fallback serves
8
+ * `index.html` for any path with no file of its own. That document carries the default locale's
9
+ * content, so a translated page asked for without the slash rendered the not-found page once the
10
+ * runtime took over — the French URL resolved against English content — and every other page got
11
+ * the home page's prerender under its own URL. ⛔ So until 2026-10-01 the preview of a build showed
12
+ * what no host it deploys to does.
13
+ *
14
+ * Only an existing folder with an `index.html` is answered, and a `<route>.html` beside it wins, as
15
+ * on those hosts. Everything else goes to Vite unchanged.
16
+ */
17
+ import { existsSync, statSync } from 'node:fs'
18
+ import { join, resolve, sep } from 'node:path'
19
+
20
+ /**
21
+ * The URL `vite preview` should serve for a request: its folder's `index.html`, or null to leave the
22
+ * request alone.
23
+ *
24
+ * @param {{ method?: string, url?: string }} req
25
+ * @param {string} outDir - the build's output directory, absolute
26
+ * @param {string} [base] - the site's deployment base (`/`, `/docs/`)
27
+ * @returns {string|null}
28
+ */
29
+ export function directoryIndexUrl(req, outDir, base = '/') {
30
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
31
+ const url = req.url || ''
32
+ const queryAt = url.indexOf('?')
33
+ const pathname = queryAt === -1 ? url : url.slice(0, queryAt)
34
+ const query = queryAt === -1 ? '' : url.slice(queryAt)
35
+
36
+ const mount = base.endsWith('/') ? base : `${base}/`
37
+ if (pathname.endsWith('/') || !pathname.startsWith(mount)) return null
38
+
39
+ let route
40
+ try {
41
+ route = decodeURIComponent(pathname.slice(mount.length - 1))
42
+ } catch {
43
+ return null
44
+ }
45
+
46
+ const dir = join(outDir, route)
47
+ if (dir !== outDir && !dir.startsWith(outDir + sep)) return null
48
+ if (existsSync(`${dir}.html`)) return null
49
+ if (!existsSync(join(dir, 'index.html')) || !statSync(dir).isDirectory()) return null
50
+ return `${pathname}/index.html${query}`
51
+ }
52
+
53
+ /** The plugin: a middleware ahead of Vite's own, in `vite preview` only. */
54
+ export function previewDirectoryIndexPlugin() {
55
+ return {
56
+ name: 'uniweb:preview-directory-index',
57
+ configurePreviewServer(server) {
58
+ const { root, base, build } = server.config
59
+ const outDir = resolve(root, build.outDir)
60
+ server.middlewares.use((req, _res, next) => {
61
+ const target = directoryIndexUrl(req, outDir, base)
62
+ if (target) req.url = target
63
+ next()
64
+ })
65
+ },
66
+ }
67
+ }
package/src/uwx/index.js CHANGED
@@ -78,6 +78,8 @@ export { matchStoredRecords } from './record-identity.js'
78
78
  // The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
79
79
  // up front and hands it to the synchronous projections above.
80
80
  export { siteSelfScope } from './self-scope.js'
81
+ // What each section type declares its data blocks are — a pull reads a translated block back by it.
82
+ export { dataBlockModels } from '../i18n/data-models.js'
81
83
  export {
82
84
  siteInfoToConfig,
83
85
  sectionRecordToFile,
@@ -23,7 +23,7 @@ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
23
23
  import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-writer'
24
24
  import { computeHash } from '../i18n/hash.js'
25
25
  import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
26
- import { visitDataStrings } from '../i18n/data-strings.js'
26
+ import { visitDataBlockStrings } from '../i18n/data-strings.js'
27
27
  import { resolveDocForLocale } from '../i18n/merge.js'
28
28
  import { computeSourceHash } from '../i18n/freeform-manifest.js'
29
29
  import { sameMarkdownDocument, canonicalJson } from './same-content.js'
@@ -167,8 +167,11 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
167
167
  * @param {string} sourceLocale
168
168
  * @param {string[]} [targetLocales]
169
169
  * @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
170
+ * @param {object} [context] - the section's translation context
171
+ * @param {(tag: string) => object|null} [modelFor] - the section's data-block models by tag
172
+ * (`i18n/data-models.js`), so a block is translated as the build translates it
170
173
  */
171
- export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined) {
174
+ export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined, modelFor = () => null) {
172
175
  // Non-docs (null, an already-localized map) pass through untouched.
173
176
  if (!isProseMirrorDoc(doc)) return doc
174
177
 
@@ -178,7 +181,7 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
178
181
  const table = translations[locale]
179
182
  if (!table) continue
180
183
  // With the section's context, so a context-specific override resolves as the build resolves it.
181
- const resolved = resolveDocForLocale(doc, table, context)
184
+ const resolved = resolveDocForLocale(doc, table, context, modelFor)
182
185
  if (resolved) result[locale] = resolved
183
186
  }
184
187
  }
@@ -363,7 +366,7 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
363
366
  * per-locale link hrefs survive. Keyed by the source element's cleaned text, so
364
367
  * `computeHash(key)` matches the extractor's unit hash — closing the round trip.
365
368
  */
366
- function deriveStructuralMap(sourceDoc, targetDoc) {
369
+ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
367
370
  if (!isProseMirrorDoc(sourceDoc) || !isProseMirrorDoc(targetDoc)) return null
368
371
  const src = blockElements(sourceDoc)
369
372
  const tgt = blockElements(targetDoc)
@@ -383,15 +386,18 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
383
386
  // a mark we can't serialize → don't risk a lossy map; store as free-form
384
387
  return null
385
388
  }
386
- return addDataBlockStrings(map, sourceDoc, targetDoc)
389
+ return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
387
390
  }
388
391
 
389
392
  // ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
390
- // (`visitDataStrings`, the extractor's judgement), keyed by their source text as the build keys them.
393
+ // (`visitDataBlockStrings`: a block's declared shape, else the extractor's heuristic), keyed by their
394
+ // source text as the build keys them. ⭐ Walked by the same model the push translated with — the pairing
395
+ // is by position, so a declared text field the heuristic skips (a `status`) would otherwise read a
396
+ // faithful translation as another shape.
391
397
  // A target that is not the source with only those strings changed — another shape, another `href` —
392
398
  // cannot be said per string, and the whole translation is free-form. ⛔ Until 2026-09-26 a data block
393
399
  // was not read here at all, and its translations were not sent (`merge.js::translateDataBlocks`).
394
- function addDataBlockStrings(map, sourceDoc, targetDoc) {
400
+ function addDataBlockStrings(map, sourceDoc, targetDoc, modelFor = () => null) {
395
401
  const srcBlocks = dataBlockNodes(sourceDoc)
396
402
  const tgtBlocks = dataBlockNodes(targetDoc)
397
403
  if (srcBlocks.length !== tgtBlocks.length) return null
@@ -405,7 +411,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
405
411
  }
406
412
  const strings = (data) => {
407
413
  const out = []
408
- visitDataStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value))
414
+ visitDataBlockStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value), modelFor(s.tag))
409
415
  return out
410
416
  }
411
417
  const from = strings(s.data)
@@ -418,7 +424,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
418
424
  pairs.set(from[j], to[j])
419
425
  }
420
426
  const rebuilt = JSON.parse(JSON.stringify(s.data))
421
- visitDataStrings(rebuilt, (value) => pairs.get(value) ?? value)
427
+ visitDataBlockStrings(rebuilt, (value) => pairs.get(value) ?? value, modelFor(s.tag))
422
428
  if (canonicalJson(rebuilt) !== canonicalJson(t.data)) return null
423
429
  for (const [from, to] of pairs) map[from.trim()] = to
424
430
  }
@@ -436,7 +442,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
436
442
  * reserved `@` (and `$`-prefixed) key is opaque metadata — NEVER a locale. A bare
437
443
  * doc (no locale wrap) is returned unchanged.
438
444
  */
439
- export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null) {
445
+ export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null, modelFor = () => null) {
440
446
  if (!isLocalizedContent(content)) return content
441
447
  const source = content[sourceLocale]
442
448
  for (const [locale, value] of Object.entries(content)) {
@@ -445,7 +451,7 @@ export function unwrapLocalizedContent(content, sourceLocale, collector, freefor
445
451
  if (isProseMirrorDoc(value)) {
446
452
  // A translation the site keeps as a free-form file stays one, even when it lines up.
447
453
  const kept = collector?.keptFreeform?.(locale, freeformCandidates || freeformRelPath) || null
448
- const map = kept ? null : deriveStructuralMap(source, value)
454
+ const map = kept ? null : deriveStructuralMap(source, value, modelFor)
449
455
  if (map) collector?.addStructuralMap?.(locale, map, context)
450
456
  else collector?.noteFreeform?.(locale, value, source, kept || freeformRelPath)
451
457
  } else {
@@ -26,6 +26,7 @@
26
26
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
28
 
29
+ import { normalizeFoundationSchema } from '@uniweb/schemas/foundation'
29
30
  import { toDataSchemaDeclaration, SHORTHAND_SECTION } from './data-schema.js'
30
31
  import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
31
32
 
@@ -81,7 +82,7 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
81
82
  const foundationEntity = {
82
83
  model: FOUNDATION_SCHEMA,
83
84
  info: buildInfo(self, org, digest, runtime),
84
- schema: buildSchemaBlob(schema),
85
+ schema: buildSchemaBlob(schema, org),
85
86
  i18n: { locales: loadI18nLocales(foundationDir) },
86
87
  'data-schemas': { refs: buildRefs(dataSchemas, scoped) },
87
88
  }
@@ -250,16 +251,27 @@ function buildInfo(self, org, digest, runtime) {
250
251
  return info
251
252
  }
252
253
 
253
- // The whole renderable schema.json MINUS identity and MINUS dataSchemas, shipped
254
- // as one opaque object the backend never reads into (custodian).
255
- function buildSchemaBlob(schema) {
254
+ // The foundation schema an editor reads: the whole renderable schema.json MINUS identity
255
+ // and MINUS dataSchemas, shipped as one object the backend stores and serves, and never
256
+ // reads into (custodian).
257
+ //
258
+ // ⭐ IN ITS NORMALIZED FORM — `schemaFormat: 3` (`@uniweb/schemas/foundation`, ruled
259
+ // 2026-10-01 [Diego]): every entry in one spelling per meaning, so the editor needs no
260
+ // framework grammar, and each `@/x` a `data:` key names qualified with the scope the
261
+ // foundation registers under — the name its Model was stored under. ⛔ Until 2026-10-01 the
262
+ // build's format 2 shipped as built, and `data: { team: '@/member' }` reached the editor
263
+ // as written. `dist/meta/schema.json` stays format 2: the build's own readers read it.
264
+ //
265
+ // It throws for a `data:` value that cannot be normalized — an inline field map that is
266
+ // not a valid data schema — naming the component and the key; the build warns of it first.
267
+ function buildSchemaBlob(schema, org) {
256
268
  const { dataSchemas: _ds, ...rest } = schema
257
269
  // Every key hoisted into `info` is stripped here, so the wire carries each
258
270
  // fact ONCE. Two copies of one fact is a drift liability, and the copy inside
259
271
  // an opaque blob is the one nobody would think to update.
260
272
  const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
261
273
  rest._self || {}
262
- return { ...rest, _self: selfConfig }
274
+ return normalizeFoundationSchema({ ...rest, _self: selfConfig }, { scope: org || undefined })
263
275
  }
264
276
 
265
277
  // The data-schemas the foundation renders, by NAME (own + shared), sorted.
@@ -403,7 +403,7 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
403
403
  * structural maps on a localized `content` field are captured into it
404
404
  * @returns {'updated'|'unchanged'}
405
405
  */
406
- export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null }) {
406
+ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null, dataModel = null }) {
407
407
  // ⛔ A stored `preset` or `input` is not written back: neither has an effect, and the build
408
408
  // would only warn about it (2026-09-27).
409
409
  // ⛔ No `background` / `theme_override` / `insets` / `fetch`: the site-content Model dropped all
@@ -416,7 +416,8 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
416
416
  // target-locale structural maps are captured into the locales/ collector, and any
417
417
  // free-form target body is captured with `freeformRelPath` for writing under
418
418
  // locales/freeform/. A bare doc (source-only / pre-localization) passes through.
419
- const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates, context)
419
+ // A data block is read back by what the section's type declares it is, as the push translated it.
420
+ const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates, context, (tag) => dataModel?.(type, tag) ?? null)
420
421
 
421
422
  const { hidden: paramHidden, fetch: paramFetch, ...paramsRest } = params && typeof params === 'object' ? params : {}
422
423
  const frontmatter = {}
@@ -556,6 +557,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
556
557
  record,
557
558
  sourceLocale: ctx?.sourceLocale,
558
559
  collector: ctx?.collector,
560
+ dataModel: ctx?.dataModel,
559
561
  freeformRelPath,
560
562
  freeformCandidates,
561
563
  writeId: fileSectionName(filePath) !== stableId,
@@ -1081,6 +1083,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1081
1083
  record,
1082
1084
  sourceLocale: ctx?.sourceLocale,
1083
1085
  collector: ctx?.collector,
1086
+ dataModel: ctx?.dataModel,
1084
1087
  writeId: fileSectionName(filePath) !== stableId,
1085
1088
  freeformRelPath: stableId ? buildFreeformPath({ stableId }, page) : null,
1086
1089
  freeformCandidates: stableId ? freeformPathsFor({ stableId }, page) : null,
@@ -1116,7 +1119,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1116
1119
  * `declarationsToQueriesYml`, whose default it overrides.
1117
1120
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
1118
1121
  */
1119
- export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
1122
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
1120
1123
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
1121
1124
 
1122
1125
  // Collects target-locale translations of localized scalars as they're projected;
@@ -1145,7 +1148,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1145
1148
  // rename detection, build a fresh one as we project, then persist it. Items not
1146
1149
  // re-projected (deleted) drop out naturally. `collector` rides along to capture
1147
1150
  // localized scalars during the page walk.
1148
- const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map() }
1151
+ const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map(), dataModel }
1149
1152
  // Where the site keeps its localized URLs — `site.yml`'s map, when it has one (`pageSlugToYml`).
1150
1153
  const siteMap = readAuthoredYaml(join(siteRoot, 'site.yml'))?.i18n?.routeTranslations
1151
1154
  ctx.routes = { siteMap: isPlainRecord(siteMap) && Object.keys(siteMap).length ? siteMap : null, slugs: new Map() }
package/src/uwx/site.js CHANGED
@@ -45,6 +45,7 @@
45
45
  // (favicon/assets — carried out of band).
46
46
 
47
47
  import { readdir, readFile } from 'node:fs/promises'
48
+ import { dataBlockModels } from '../i18n/data-models.js'
48
49
  import { existsSync } from 'node:fs'
49
50
  import { join, parse, relative } from 'node:path'
50
51
  import {
@@ -245,6 +246,8 @@ export function buildRouteOf(record, parentRoute, sourceLocale) {
245
246
  // hits the filesystem.
246
247
  async function localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot) {
247
248
  const freeformBase = localesDir(siteRoot)
249
+ // What each section type declares its data blocks are, so a block is pushed as the build translates it.
250
+ const dataModel = await dataBlockModels(siteRoot)
248
251
 
249
252
  const localizeSection = async (record, page) => {
250
253
  if (!record.content) return
@@ -252,7 +255,7 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
252
255
  // Resolved in the section's context, as the build resolves it (`translationContext`): a
253
256
  // context-specific override applies here and nowhere else. ⛔ Until 2026-09-26 every section was
254
257
  // resolved with none, so an override never reached a backend.
255
- let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route))
258
+ let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route), (tag) => dataModel(record.type, tag))
256
259
  if (page) {
257
260
  const section = { stableId }
258
261
  for (const locale of targetLocales) {