@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.
@@ -44,6 +44,7 @@ import { readFileSync, existsSync } from 'node:fs'
44
44
  import yaml from 'js-yaml'
45
45
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
46
46
  import { detectFoundationType } from '../site/foundation-ref.js'
47
+ import { foundationSchemaJson } from '../site/queries-config.js'
47
48
  import { join, resolve } from 'node:path'
48
49
 
49
50
  import { resolveQueriesConfig } from './queries-config.js'
@@ -55,14 +56,14 @@ import {
55
56
  poolPathReadings,
56
57
  poolDirsForSchema,
57
58
  } from '../site/entity-pool.js'
58
- import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField } from './data-schema.js'
59
+ import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isOpenMapSection, openMapEntries } from './data-schema.js'
59
60
  import { recordFileLayout, contentBodyTarget } from './record-layout.js'
60
61
  import { emitEntitySyncPackage } from './entity-document.js'
61
62
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
62
63
  import { sha256Hex, toJsonBuffer } from './manifest.js'
63
64
  import { isUuid } from './uuid.js'
64
65
  import { markdownToProseMirror } from '@uniweb/content-reader'
65
- import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
66
+ import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
66
67
  import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
67
68
  import { loadFreeformRecord } from '../i18n/freeform.js'
68
69
  import { isDraftRecord } from '../site/record-draft.js'
@@ -164,7 +165,10 @@ export function entityContentHash(document) {
164
165
  return sha256Hex(toJsonBuffer(stripSigils(document)))
165
166
  }
166
167
 
167
- function encodeFieldValue(value, field, sourceLocale, translations) {
168
+ // ⭐ `context` is the record the value is in — `{ key: '<pool>/<handle>' }`, the identity its
169
+ // translations are kept under (`i18n/records.js`) — so an entry that reads differently in some
170
+ // records (`{ default, overrides }`) is sent as each one renders it.
171
+ function encodeFieldValue(value, field, sourceLocale, translations, context = null) {
168
172
  if (value == null) return value
169
173
  if (isProseMirrorField(field)) {
170
174
  // markdown source → ProseMirror doc. When localized, wrap per-locale exactly
@@ -172,7 +176,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
172
176
  // path, flushed to locales/records/{locale}.json by the caller.
173
177
  const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
174
178
  if (!field.localized) return doc
175
- const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
179
+ const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations, context)
176
180
  // localizeContentDoc returns a BARE doc when there are no target locales. A
177
181
  // localized field MUST ride as a `{ lang: value }` map on the wire — the
178
182
  // schema-driven projector drops a localized field whose value isn't a map — so
@@ -184,15 +188,14 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
184
188
  // element — the shape page `keywords` already take. ⛔ It went up as a bare list
185
189
  // until 2026-09-24: `localizeScalar` passes any object through, arrays included.
186
190
  if (Array.isArray(value) && !isMarkupTextField(field)) {
187
- return localizeScalarList(value, sourceLocale, translations)
191
+ return localizeScalarList(value, sourceLocale, translations, context)
188
192
  }
189
- // A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
190
- // per-locale wholesale (its per-string translations live in the i18n manifest /
191
- // free-form, not the scalar map). Other localized scalars wrap per-string from
192
- // locales/records/{locale}.json.
193
- return isMarkupTextField(field)
194
- ? localize(value, sourceLocale)
195
- : localizeScalar(value, sourceLocale, translations)
193
+ // A markup `text` field (format markdown|html) rides as its RAW source, and its
194
+ // translation is the one the build renders: the whole value, keyed by the value
195
+ // (`i18n/records.js` extracts and translates it as one unit) — so it is wrapped
196
+ // per string like any localized scalar. ⛔ Until 2026-09-26 it went up as its
197
+ // source alone (`{ en }`), though the static build rendered its translation.
198
+ return localizeScalar(value, sourceLocale, translations, context)
196
199
  }
197
200
  // A Date handed in by a caller. The backend validates `date` as `YYYY-MM-DD` and
198
201
  // `datetime` as RFC3339 — emitting full ISO for a `date` field is rejected before
@@ -266,6 +269,25 @@ export function recordsToEntities({
266
269
  `"${bodyTarget.section}.${bodyTarget.key}"`
267
270
  )
268
271
  }
272
+ // ⛔ A SITE'S FOLDER PLACES ONLY A RECORD SOMETHING CAN REFERENCE. A folder entry is a
273
+ // reference, and a Model with no brief is not linkable (`toDataSchemaDeclaration`), so a
274
+ // backend refuses to place its records — measured 2026-09-25 as a `409 … linkable: false`,
275
+ // and by design (backend, 2026-09-26). Refused here, once for the schema, before anything
276
+ // is sent — and saying why, which the backend's refusal names only by its design doc.
277
+ if (declaration.linkable === false) {
278
+ const slugs = (records || []).map((r) => r?.slug).filter(Boolean)
279
+ if (slugs.length) {
280
+ const named = slugs.slice(0, 3).join(', ') + (slugs.length > 3 ? ` and ${slugs.length - 3} more` : '')
281
+ refusals.push(
282
+ `${label}: ${declaration.name} has no brief, so a site's folder cannot place its records ` +
283
+ `(${named}) — a folder entry is a reference, and only a record of a schema with a brief ` +
284
+ 'can be one. Give the schema a brief (`brief: true` on one of its sections), or keep this ' +
285
+ 'data out of records/.'
286
+ )
287
+ }
288
+ return { entities, warnings, refusals }
289
+ }
290
+
269
291
  for (const record of records || []) {
270
292
  const slug = record.slug
271
293
  if (!slug) {
@@ -291,14 +313,15 @@ export function recordsToEntities({
291
313
  const uuid = record.$uuid || null
292
314
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
293
315
 
294
- const enc = encoder(sourceLocale, translations, refs)
316
+ const context = { key: `${label}/${slug}` } // as the build keys the record's translations
317
+ const enc = encoder(sourceLocale, translations, refs, context)
295
318
  const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
296
319
  // The markdown body fills the content body field, in whatever section declares it,
297
320
  // unless the file already sets that field.
298
321
  if (hasBody && bodyTarget) {
299
322
  const data = (sections[bodyTarget.section] ??= {})
300
323
  if (data[bodyTarget.key] == null) {
301
- data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
324
+ data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations, context)
302
325
  }
303
326
  }
304
327
  // The brief is the card, and is always sent — an empty one included, so a required
@@ -381,8 +404,8 @@ export function recordsToEntities({
381
404
  // What one record's read collects beside the values: the keys with no place in the
382
405
  // schema, the keys written flat in a schema written by section, and values of the
383
406
  // wrong shape for their section.
384
- function encoder(sourceLocale, translations, refs = null) {
385
- return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
407
+ function encoder(sourceLocale, translations, refs = null, context = null) {
408
+ return { sourceLocale, translations, refs, context, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
386
409
  }
387
410
 
388
411
  // A flat record: its keys, minus the record's own, are the one section's fields.
@@ -426,6 +449,20 @@ function readBySection(layout, record, enc) {
426
449
  // are its child sections), a list of records for a `many` one.
427
450
  function encodeSection(def, value, enc, path) {
428
451
  if (def.multiple === true) {
452
+ // ⭐ AN OPEN MAP is the author's map in a file and rows on the wire (`isOpenMapSection`): each
453
+ // key becomes its row's `name`. ⛔ Until 2026-09-26 the map was refused as "a list of records",
454
+ // so no record holding one could be pushed. Rows written as a list are read as they are.
455
+ if (isOpenMapSection(def) && isPlainObject(value)) {
456
+ const rows = []
457
+ for (const { key, value: entry, row } of openMapEntries(value)) {
458
+ if (!isPlainObject(entry)) {
459
+ enc.shape.push(`"${path}.${key}" is one entry of a map — write it as a map of its fields.`)
460
+ continue
461
+ }
462
+ rows.push(encodeItem(def, row, enc, `${path}.${key}`))
463
+ }
464
+ return rows
465
+ }
429
466
  if (!Array.isArray(value)) {
430
467
  enc.shape.push(`"${path}" holds a list of records — write it as a list ("- …" under "${path}:").`)
431
468
  return undefined
@@ -473,7 +510,7 @@ function encodeRecord(fields, value, enc, prefix, reserved) {
473
510
  ? encodeSection(field, v, enc, `${prefix}${key}`)
474
511
  : field?.type === 'entity_ref'
475
512
  ? encodeReference(v, field, enc, `${prefix}${key}`)
476
- : encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
513
+ : encodeFieldValue(v, field, enc.sourceLocale, enc.translations, enc.context)
477
514
  if (encoded !== undefined) out[key] = encoded
478
515
  }
479
516
  for (const key of Object.keys(value)) {
@@ -754,6 +791,17 @@ function resolveFoundationDir(siteRoot, opts) {
754
791
  }
755
792
  }
756
793
 
794
+ // The site's foundation's schema as a project keeps it for a catalog ref (`foundationSchemaJson`,
795
+ // `site/registered-foundation.js`) — its section types, which is all this is read for.
796
+ function registeredFoundationSchema(siteRoot) {
797
+ try {
798
+ const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
799
+ return foundationSchemaJson(siteRoot, siteYml)
800
+ } catch {
801
+ return null
802
+ }
803
+ }
804
+
757
805
  // Load the local foundation's built schema.json (the source of locally-defined
758
806
  // Model declarations), or null when there's no local foundation. `required` (set
759
807
  // when no remote resolver is available) turns "missing" into a helpful error
@@ -985,7 +1033,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
985
1033
  // holds records of that type. `keyTyped` records each such name for the `queries` Section,
986
1034
  // which must name the same data schema (`sync-package.js` hands it on). ⛔ Never for a schema
987
1035
  // a query asked for explicitly: that one is the author's, and fails loudly.
988
- const keyTypes = dataKeyTypes(localSchema)
1036
+ // A clone has no local foundation: its section types come from the registered version it keeps.
1037
+ const keyTypes = dataKeyTypes(localSchema || registeredFoundationSchema(siteRoot))
989
1038
  const keyTyped = new Map()
990
1039
  const typedFor = async (schema) => {
991
1040
  const key = keyOfDefaultRef(schema)
@@ -0,0 +1,43 @@
1
+ // Do two renderings of an author's content say the same thing? A pull that changes nothing must leave
2
+ // the author's file as they wrote it, and the writer's formatting is not theirs — so a pull compares
3
+ // meaning, not text, before it writes.
4
+ //
5
+ // A leaf, so the writers that compare this way — `project-writer.js` (sections, YAML config, record
6
+ // files), `locale-sync.js` (free-form translations) and `records-project.js` (list files) — share one
7
+ // rule without importing each other.
8
+
9
+ import { markdownToProseMirror } from '@uniweb/content-reader'
10
+
11
+ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
12
+
13
+ /** JSON with every object's keys sorted — equal for equal values, whatever the order. */
14
+ export const canonicalJson = (value) =>
15
+ JSON.stringify(value, (_, v) =>
16
+ isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
17
+ )
18
+
19
+ /**
20
+ * Does this markdown parse to `doc`, the way a push parses it?
21
+ *
22
+ * @param {string} markdown - an author's markdown body
23
+ * @param {object} doc - a ProseMirror document
24
+ * @returns {boolean}
25
+ */
26
+ export function sameMarkdownDocument(markdown, doc) {
27
+ try {
28
+ return canonicalJson(withoutDefaults(markdownToProseMirror(markdown))) === canonicalJson(withoutDefaults(doc))
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ // A document without the attributes that only restate a default: an inset's `embedKind: 'visual'`,
35
+ // which the parser writes and the pull's re-inlining leaves out (`reinlineInsets`) — so a body holding
36
+ // an inset never compared as unchanged (measured 2026-09-26 on the `marketing` template's hero).
37
+ function withoutDefaults(doc) {
38
+ return JSON.parse(JSON.stringify(doc), (_, v) => {
39
+ if (v?.type !== 'inset_ref' || v.attrs?.embedKind !== 'visual') return v
40
+ const { embedKind: _visual, ...attrs } = v.attrs
41
+ return { ...v, attrs }
42
+ })
43
+ }