@uniweb/build 0.63.4 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/i18n/extract.js +24 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +22 -17
- package/src/i18n/records.js +389 -161
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/query-processor.js +13 -2
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/project-writer.js +106 -31
- package/src/uwx/records-project.js +108 -21
- package/src/uwx/records.js +19 -16
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +322 -38
- package/src/uwx/site.js +146 -35
|
@@ -16,7 +16,9 @@ import { randomBytes } from 'node:crypto'
|
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
18
18
|
import { proseMirrorToMarkdown, serializeFrontmatter } from '@uniweb/content-writer'
|
|
19
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
19
20
|
import { parseFrontmatter } from './entity-source.js'
|
|
21
|
+
import { canonicalJson, sameMarkdownDocument } from './same-content.js'
|
|
20
22
|
import { renderEntityDocument } from './backfill.js'
|
|
21
23
|
import { queriesYmlPath } from './queries-config.js'
|
|
22
24
|
import { folderYmlPath } from '../site/records-config.js'
|
|
@@ -43,7 +45,7 @@ export const DEFAULT_RESERVED_FRONTMATTER = new Set([
|
|
|
43
45
|
])
|
|
44
46
|
|
|
45
47
|
// js-yaml dump options shared by every config write, so output is byte-stable.
|
|
46
|
-
const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
|
|
48
|
+
export const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
51
|
* Write `text` to `filePath` only when it differs from what's on disk, using a
|
|
@@ -164,9 +166,11 @@ function assembleSection(frontmatter, body) {
|
|
|
164
166
|
* @param {object} [opts.content] - ProseMirror document for the body
|
|
165
167
|
* @param {object} [opts.params] - incoming frontmatter params to merge
|
|
166
168
|
* @param {Set<string>} [opts.reserved] - keys preserved when already present
|
|
169
|
+
* @param {object} [opts.implied] - `{ key: value }` a file means by leaving `key` out; an incoming
|
|
170
|
+
* value equal to it is not written into an existing file that leaves the key out
|
|
167
171
|
* @returns {'updated'|'unchanged'}
|
|
168
172
|
*/
|
|
169
|
-
export function writeSectionFile({ filePath, content, params, reserved = DEFAULT_RESERVED_FRONTMATTER }) {
|
|
173
|
+
export function writeSectionFile({ filePath, content, params, reserved = DEFAULT_RESERVED_FRONTMATTER, implied = null }) {
|
|
170
174
|
let existing = ''
|
|
171
175
|
try {
|
|
172
176
|
existing = readFileSync(filePath, 'utf8')
|
|
@@ -185,15 +189,26 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
185
189
|
// how a newly-projected section gets its `type`/`nest`/etc.
|
|
186
190
|
if (reserved.has(key) && key in frontmatter) continue
|
|
187
191
|
if (reserved.has(key) && DECLARATION_KEYS.includes(key) && declaresLocally) continue
|
|
192
|
+
// A value the file implies by leaving the key out (`implied`) is not written into a file that
|
|
193
|
+
// exists — a new file has nothing to imply it with.
|
|
194
|
+
if (implied && existing && !(key in frontmatter) && implied[key] === value) continue
|
|
188
195
|
if (value === null || value === undefined) delete nextFrontmatter[key]
|
|
189
196
|
else nextFrontmatter[key] = value
|
|
190
197
|
}
|
|
191
198
|
}
|
|
192
199
|
|
|
193
|
-
|
|
200
|
+
// ⭐ AN UNCHANGED BODY KEEPS THE AUTHOR'S OWN TEXT. A body that parses to the document the pull brought
|
|
201
|
+
// is not re-serialized: the writer's markdown differs from an author's in blank lines and line breaks,
|
|
202
|
+
// and a pull that changed nothing reformatted every section it touched (measured 2026-09-26 on the
|
|
203
|
+
// `marketing` template). A file with nothing to change is not written at all.
|
|
204
|
+
const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
|
|
205
|
+
const keepsBody = Boolean(content && authoredBody) && sameMarkdownDocument(authoredBody, content)
|
|
206
|
+
if (keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
|
|
207
|
+
const body = content && !keepsBody ? proseMirrorToMarkdown(content) : authoredBody
|
|
194
208
|
return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
|
|
195
209
|
}
|
|
196
210
|
|
|
211
|
+
|
|
197
212
|
// Shallow-merge `changes` into a YAML config file and write idempotently. A key
|
|
198
213
|
// whose value is null/undefined is deleted; an object value is shallow-merged one
|
|
199
214
|
// level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
|
|
@@ -202,12 +217,8 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
202
217
|
// config, but comment-preserving merges for hand-authored config files are a
|
|
203
218
|
// quality bar to revisit.
|
|
204
219
|
function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
existing = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
|
|
208
|
-
} catch {
|
|
209
|
-
// missing / invalid → start fresh
|
|
210
|
-
}
|
|
220
|
+
const prior = loadYamlFile(filePath)
|
|
221
|
+
const existing = isPlainObject(prior.value) ? { ...prior.value } : {}
|
|
211
222
|
for (const [key, value] of Object.entries(changes)) {
|
|
212
223
|
if (value === null || value === undefined) {
|
|
213
224
|
delete existing[key]
|
|
@@ -216,13 +227,32 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
216
227
|
// pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
|
|
217
228
|
// schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
|
|
218
229
|
// object has nothing to keep, so the incoming value replaces it.
|
|
219
|
-
const
|
|
220
|
-
existing[key] = isPlainObject(
|
|
230
|
+
const current = existing[key]
|
|
231
|
+
existing[key] = isPlainObject(current) ? { ...current, ...value } : value
|
|
221
232
|
} else {
|
|
222
233
|
existing[key] = value
|
|
223
234
|
}
|
|
224
235
|
}
|
|
225
|
-
return
|
|
236
|
+
return writeYamlIfChanged(filePath, existing, prior)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A YAML file, parsed — and whether there was one: a missing or unreadable file is never "unchanged".
|
|
240
|
+
function loadYamlFile(filePath) {
|
|
241
|
+
try {
|
|
242
|
+
return { value: yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) ?? {}, read: true }
|
|
243
|
+
} catch {
|
|
244
|
+
return { value: {}, read: false }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ⭐ A FILE WHOSE MEANING WOULD NOT CHANGE IS LEFT AS ITS AUTHOR WROTE IT. Every writer here re-dumps the
|
|
249
|
+
// whole file — its comments dropped, its strings re-quoted, its lists reflowed — so a pull that restated
|
|
250
|
+
// a file rewrote it all the same. ⛔ Until 2026-09-26 a pull into the copy that pushed it re-dumped
|
|
251
|
+
// `site.yml`, `theme.yml` and every `page.yml` (measured on the `international` template: every comment
|
|
252
|
+
// in its `site.yml` gone, and nothing had changed).
|
|
253
|
+
function writeYamlIfChanged(filePath, value, prior) {
|
|
254
|
+
if (prior.read && canonicalJson(value) === canonicalJson(prior.value)) return 'unchanged'
|
|
255
|
+
return writeIfChanged(filePath, yaml.dump(value, YAML_DUMP_OPTS))
|
|
226
256
|
}
|
|
227
257
|
|
|
228
258
|
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -249,7 +279,7 @@ export function writeSiteConfig(siteRoot, config) {
|
|
|
249
279
|
* @returns {'updated'|'unchanged'}
|
|
250
280
|
*/
|
|
251
281
|
export function writeYamlFile(filePath, obj) {
|
|
252
|
-
return
|
|
282
|
+
return writeYamlIfChanged(filePath, obj || {}, loadYamlFile(filePath))
|
|
253
283
|
}
|
|
254
284
|
|
|
255
285
|
/**
|
|
@@ -269,17 +299,17 @@ export function writeYamlFile(filePath, obj) {
|
|
|
269
299
|
* @returns {'updated'|'unchanged'}
|
|
270
300
|
*/
|
|
271
301
|
export function writeMergedYaml(filePath, projected, managedKeys) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
302
|
+
const prior = loadYamlFile(filePath)
|
|
303
|
+
const existing = isPlainObject(prior.value) ? prior.value : {}
|
|
304
|
+
const managed = new Set(managedKeys)
|
|
305
|
+
// Each key where the file has it — a value the pull changes stays in its place — then the new ones.
|
|
306
|
+
const out = {}
|
|
307
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
308
|
+
if (key in projected) out[key] = projected[key]
|
|
309
|
+
else if (!managed.has(key)) out[key] = value
|
|
277
310
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
for (const key of managedKeys) delete out[key]
|
|
281
|
-
Object.assign(out, projected)
|
|
282
|
-
return writeIfChanged(filePath, yaml.dump(out, YAML_DUMP_OPTS))
|
|
311
|
+
for (const [key, value] of Object.entries(projected)) if (!(key in out)) out[key] = value
|
|
312
|
+
return writeYamlIfChanged(filePath, out, prior)
|
|
283
313
|
}
|
|
284
314
|
|
|
285
315
|
/**
|
|
@@ -309,12 +339,31 @@ export function writeQueriesConfig(siteRoot, queries) {
|
|
|
309
339
|
// `events:` is `{}`), which a pull brings back in its long form. And a pull that
|
|
310
340
|
// restates every query writes nothing: the file is re-dumped whole otherwise, which
|
|
311
341
|
// rewrites a bare `events:` as `events: null` and flow style as block style.
|
|
342
|
+
//
|
|
343
|
+
// ⭐ WHEREVER THE AUTHOR DECLARED IT — `queries.yml`, or `site.yml`'s `queries:` block, which the
|
|
344
|
+
// build reads too, under it. ⛔ Until 2026-09-26 only `queries.yml` was consulted, so a site whose
|
|
345
|
+
// queries live in `site.yml` (the `international` template) got a `queries.yml` repeating every one
|
|
346
|
+
// of them on each pull, in the long form. A query that changed is written back where it lives.
|
|
347
|
+
let site = {}
|
|
348
|
+
try {
|
|
349
|
+
site = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
|
|
350
|
+
} catch {
|
|
351
|
+
// missing / invalid → nothing declared there
|
|
352
|
+
}
|
|
353
|
+
const inSite = isPlainObject(site.queries) ? site.queries : {}
|
|
312
354
|
const changes = {}
|
|
355
|
+
const siteChanges = {}
|
|
313
356
|
for (const [name, decl] of Object.entries(queries)) {
|
|
357
|
+
const inFile = isPlainObject(authored) && name in authored
|
|
358
|
+
if (!inFile && name in inSite) {
|
|
359
|
+
if (!restates(inSite[name], decl)) siteChanges[name] = decl
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
314
362
|
if (restates(authored?.[name], decl)) continue
|
|
315
363
|
changes[name] = decl
|
|
316
364
|
}
|
|
317
|
-
if (Object.keys(
|
|
365
|
+
if (Object.keys(siteChanges).length) writeSiteConfig(siteRoot, { queries: siteChanges })
|
|
366
|
+
if (Object.keys(changes).length === 0) return Object.keys(siteChanges).length ? 'updated' : 'unchanged'
|
|
318
367
|
// ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
|
|
319
368
|
// the local one — what the call did until 2026-09-23 — a key the backend's
|
|
320
369
|
// declaration no longer has survived every pull: the defect `writeSiteConfig`
|
|
@@ -330,11 +379,6 @@ function restates(authored, decl) {
|
|
|
330
379
|
return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
|
|
331
380
|
}
|
|
332
381
|
|
|
333
|
-
// JSON with every object's keys sorted — equal for equal values, whatever the order.
|
|
334
|
-
const canonicalJson = (value) =>
|
|
335
|
-
JSON.stringify(value, (_, v) =>
|
|
336
|
-
isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
|
|
337
|
-
)
|
|
338
382
|
|
|
339
383
|
/**
|
|
340
384
|
* Write the records folder's organization — `folder.yml` in the records directory
|
|
@@ -382,9 +426,40 @@ export function writeThemeFile(siteRoot, theme) {
|
|
|
382
426
|
* body (so a target-locale full-doc body is captured for locales/freeform/)
|
|
383
427
|
* @param {(model: string, uuid: string) => string|null} [opts.refName] - the name a
|
|
384
428
|
* referenced record goes by, so a reference is written as that name
|
|
429
|
+
* @param {{ key: string }} [opts.context] - the record's identity, `<pool>/<handle>`, which
|
|
430
|
+
* its translations are kept under — an entry that differs from record to record
|
|
431
|
+
* comes back as `{ default, overrides }`
|
|
385
432
|
* @returns {'updated'|'unchanged'}
|
|
386
433
|
*/
|
|
387
|
-
export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
|
|
388
|
-
const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName })
|
|
434
|
+
export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
|
|
435
|
+
const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
436
|
+
// ⭐ A record the pull did not change keeps the author's file — its key order, its blank lines, its
|
|
437
|
+
// quoting. ⛔ Until 2026-09-26 each one was re-rendered (measured on the `international` template).
|
|
438
|
+
let current = null
|
|
439
|
+
try {
|
|
440
|
+
current = readFileSync(filePath, 'utf8')
|
|
441
|
+
} catch {
|
|
442
|
+
// new file
|
|
443
|
+
}
|
|
444
|
+
if (current !== null && sameRecordText(current, text, format, filePath)) return 'unchanged'
|
|
389
445
|
return writeIfChanged(filePath, text)
|
|
390
446
|
}
|
|
447
|
+
|
|
448
|
+
// Do two renderings of a record file say the same thing — the same data, and a body that parses to
|
|
449
|
+
// the same document?
|
|
450
|
+
function sameRecordText(a, b, format, filePath) {
|
|
451
|
+
try {
|
|
452
|
+
if (format === 'md') {
|
|
453
|
+
const x = parseFrontmatter(a, filePath)
|
|
454
|
+
const y = parseFrontmatter(b, filePath)
|
|
455
|
+
if (canonicalJson(x.frontmatter) !== canonicalJson(y.frontmatter)) return false
|
|
456
|
+
const bx = (x.body || '').trim()
|
|
457
|
+
const by = (y.body || '').trim()
|
|
458
|
+
return bx === by || sameMarkdownDocument(bx, markdownToProseMirror(by))
|
|
459
|
+
}
|
|
460
|
+
const parse = format === 'json' ? JSON.parse : (t) => yaml.load(t, YAML_OPTIONS)
|
|
461
|
+
return canonicalJson(parse(a)) === canonicalJson(parse(b))
|
|
462
|
+
} catch {
|
|
463
|
+
return false
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
import { readBackendState, updateBackendMap } from './sync-store.js'
|
|
36
36
|
import { restoreAssetRefs } from './asset-map.js'
|
|
37
37
|
import { documentSchema } from './entity-document.js'
|
|
38
|
-
import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
39
|
-
import { join, resolve, relative, extname, basename, sep } from 'node:path'
|
|
38
|
+
import { readFileSync, readdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
39
|
+
import { join, resolve, relative, dirname, extname, basename, sep } from 'node:path'
|
|
40
40
|
import yaml from 'js-yaml'
|
|
41
41
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
42
42
|
import { parseFrontmatter } from './entity-source.js'
|
|
43
|
-
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
43
|
+
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig, YAML_DUMP_OPTS } from './project-writer.js'
|
|
44
|
+
import { canonicalJson } from './same-content.js'
|
|
44
45
|
import { defaultSchema, deferredFromSchema, foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
45
46
|
import { dataKeyTypes } from './data-key-types.js'
|
|
46
47
|
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
@@ -48,7 +49,10 @@ import { folderYmlPath } from '../site/records-config.js'
|
|
|
48
49
|
import { contentBodyTarget } from './record-layout.js'
|
|
49
50
|
import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
|
|
50
51
|
import { parseCatalogRef } from '../site/foundation-ref.js'
|
|
51
|
-
import { unwrapLocalized } from './backfill.js'
|
|
52
|
+
import { unwrapLocalized, renderEntityDocument } from './backfill.js'
|
|
53
|
+
import { parseBibtex } from '@citestyle/bibtex'
|
|
54
|
+
import { getSchema as getStandardSchema, isStandardSchema } from '@uniweb/schemas'
|
|
55
|
+
import { validateAndNormalizeSchema } from '../resolve-data-schema.js'
|
|
52
56
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
53
57
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
54
58
|
|
|
@@ -97,6 +101,52 @@ export function findRecordFileByUuid(poolDir, uuid) {
|
|
|
97
101
|
return null
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
// ⭐ A RECORD KEPT IN A LIST FILE — a JSON or YAML array, or a BibTeX file: several records in one —
|
|
105
|
+
// is found by its entry's `$uuid`, which the push's back-fill writes into each entry. ⛔ Until
|
|
106
|
+
// 2026-09-25 a pull looked in single-record files alone, wrote each such record again as a file of
|
|
107
|
+
// its own beside the list, and the next push held it twice — measured on the `international`
|
|
108
|
+
// template's `records/team/team.json`.
|
|
109
|
+
export function findListEntryByUuid(poolDir, uuid) {
|
|
110
|
+
if (!uuid || !existsSync(poolDir)) return null
|
|
111
|
+
for (const name of readdirSync(poolDir)) {
|
|
112
|
+
if (name.startsWith('_')) continue
|
|
113
|
+
const ext = extname(name).toLowerCase()
|
|
114
|
+
const format = ext === '.bib' ? 'bib' : formatForExt(ext)
|
|
115
|
+
if (format !== 'json' && format !== 'yaml' && format !== 'bib') continue
|
|
116
|
+
const path = join(poolDir, name)
|
|
117
|
+
let entries
|
|
118
|
+
try {
|
|
119
|
+
const text = readFileSync(path, 'utf8')
|
|
120
|
+
entries = format === 'json' ? JSON.parse(text) : format === 'yaml' ? yaml.load(text, YAML_OPTIONS) : parseBibtex(text)
|
|
121
|
+
} catch {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(entries)) continue
|
|
125
|
+
const index = entries.findIndex((e) => e && typeof e === 'object' && e.$uuid === uuid)
|
|
126
|
+
if (index >= 0) return { path, format, index, list: true }
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A pulled record written back into its entry of a JSON or YAML list, every other entry untouched.
|
|
132
|
+
// The entry keeps its `slug`, which names it where a single-record file's name would.
|
|
133
|
+
function writeRecordIntoList({ list, document, declaration, sourceLocale, collector, freeformRelPath, refName, context = null }) {
|
|
134
|
+
const text = readFileSync(list.path, 'utf8')
|
|
135
|
+
const entries = list.format === 'json' ? JSON.parse(text) : yaml.load(text, YAML_OPTIONS)
|
|
136
|
+
const rendered = renderEntityDocument({ document, declaration, format: list.format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
137
|
+
const { $uuid, ...fields } = list.format === 'json' ? JSON.parse(rendered) : yaml.load(rendered, YAML_OPTIONS)
|
|
138
|
+
const slug = entries[list.index]?.slug
|
|
139
|
+
const entry = { ...($uuid ? { $uuid } : {}), ...(slug !== undefined ? { slug } : {}), ...fields }
|
|
140
|
+
// ⭐ An entry the pull did not change leaves the file as the author wrote it — its key order, its
|
|
141
|
+
// spacing, and a YAML file's comments. ⛔ Until 2026-09-26 the file was compared as text and re-dumped.
|
|
142
|
+
if (canonicalJson(entries[list.index]) === canonicalJson(entry)) return 'unchanged'
|
|
143
|
+
const next = entries.map((e, i) => (i === list.index ? entry : e))
|
|
144
|
+
const out = list.format === 'json' ? JSON.stringify(next, null, 2) + '\n' : yaml.dump(next, YAML_DUMP_OPTS)
|
|
145
|
+
if (out === text) return 'unchanged'
|
|
146
|
+
writeFileSync(list.path, out)
|
|
147
|
+
return 'updated'
|
|
148
|
+
}
|
|
149
|
+
|
|
100
150
|
// The format to give a NEW record file in a collection: match the collection's
|
|
101
151
|
// existing single-record files, else markdown when the schema has a content body field
|
|
102
152
|
// (so the body has a home), else YAML.
|
|
@@ -271,19 +321,34 @@ const DECL_WIRE_CONSUMED = new Set([
|
|
|
271
321
|
'queryable'
|
|
272
322
|
])
|
|
273
323
|
|
|
324
|
+
// A standard schema (`@std/<name>`), normalized as the build resolves one — known with no foundation
|
|
325
|
+
// on disk. ⛔ Until 2026-09-26 a clone, which has none, could not tell a derived `deferred:` from an
|
|
326
|
+
// authored one and wrote it into the author's queries (`international`'s `articles`, over `@std/article`).
|
|
327
|
+
function standardDataSchema(ref) {
|
|
328
|
+
const m = typeof ref === 'string' ? /^@std\/([^/]+)$/.exec(ref) : null
|
|
329
|
+
if (!m || !isStandardSchema(m[1])) return null
|
|
330
|
+
try {
|
|
331
|
+
return validateAndNormalizeSchema(getStandardSchema(m[1]), ref)
|
|
332
|
+
} catch {
|
|
333
|
+
return null
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
274
337
|
// Is this wire `deferred` exactly what the schema's brief would have derived? Compared
|
|
275
338
|
// as an ORDER-INSENSITIVE set: the deriver walks the schema's sections, and a round trip
|
|
276
339
|
// through YAML and the store is not obliged to preserve that order. Comparing as a list
|
|
277
340
|
// would classify a reordered-but-identical value as authored, and persist it.
|
|
278
|
-
function isDerivedDeferred(d, dataSchemas) {
|
|
279
|
-
if (!
|
|
280
|
-
|
|
341
|
+
function isDerivedDeferred(d, dataSchemas, pulledModel = null) {
|
|
342
|
+
if (!Array.isArray(d.deferred)) return false
|
|
343
|
+
// A clone has no foundation to read a schema from: it judges by the Model the pull read from the
|
|
344
|
+
// backend (`pulledModel`), and a standard schema is known in any case (`standardDataSchema`).
|
|
345
|
+
const derived = deferredFromSchema(dataSchemas?.[d.schema] ?? pulledModel ?? standardDataSchema(d.schema))
|
|
281
346
|
if (!derived || derived.length !== d.deferred.length) return false
|
|
282
347
|
const a = new Set(derived)
|
|
283
348
|
return d.deferred.every((f) => a.has(f))
|
|
284
349
|
}
|
|
285
350
|
|
|
286
|
-
function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null) {
|
|
351
|
+
function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null) {
|
|
287
352
|
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
288
353
|
// `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
|
|
289
354
|
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
@@ -346,7 +411,7 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
|
|
|
346
411
|
//
|
|
347
412
|
// ⚖️ Only an EQUAL value is dropped. An author who deliberately writes a narrower or
|
|
348
413
|
// wider `deferred:` than the brief implies has expressed intent, and that survives.
|
|
349
|
-
if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas)) {
|
|
414
|
+
if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas, models?.[wire.schema] ?? null)) {
|
|
350
415
|
decl.deferred = d.deferred
|
|
351
416
|
}
|
|
352
417
|
// ⛔ A stored `detail_url` is not written back: `detailUrl:` is retired (2026-09-13)
|
|
@@ -409,7 +474,7 @@ function authoredQuerySchemas(siteRoot, siteYml) {
|
|
|
409
474
|
return out
|
|
410
475
|
}
|
|
411
476
|
|
|
412
|
-
export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
|
|
477
|
+
export function declarationsToQueriesYml({ document, siteRoot, scope, models = null, ...rest }) {
|
|
413
478
|
refuseOrgOption(rest, 'declarationsToQueriesYml')
|
|
414
479
|
const decls = Array.isArray(document?.queries) ? document.queries : []
|
|
415
480
|
const report = {}
|
|
@@ -438,7 +503,7 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
438
503
|
|
|
439
504
|
const queries = {}
|
|
440
505
|
for (const d of decls) {
|
|
441
|
-
const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored)
|
|
506
|
+
const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored, models)
|
|
442
507
|
if (!name) continue
|
|
443
508
|
queries[name] = decl
|
|
444
509
|
}
|
|
@@ -524,7 +589,8 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
524
589
|
unplaceable = true
|
|
525
590
|
continue
|
|
526
591
|
}
|
|
527
|
-
|
|
592
|
+
// A list file holds several records and is one path — listed once.
|
|
593
|
+
if (!out.includes(rel)) out.push(rel)
|
|
528
594
|
}
|
|
529
595
|
return out
|
|
530
596
|
}
|
|
@@ -589,7 +655,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
589
655
|
// Captures target-locale translations of localized record fields: SCALARs →
|
|
590
656
|
// locales/records/{locale}.json (structural maps too), and a prosemirror
|
|
591
657
|
// BODY's free-form per-locale override → locales/freeform/{locale}/records/.
|
|
592
|
-
const collector = createTranslationCollector(sourceLocale)
|
|
658
|
+
const collector = createTranslationCollector(sourceLocale, { siteRoot })
|
|
593
659
|
const updated = []
|
|
594
660
|
const placed = []
|
|
595
661
|
const unchanged = []
|
|
@@ -616,13 +682,19 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
616
682
|
// The file holding a record already, by its own id: the derived folder first, then any
|
|
617
683
|
// other that reads as the same Model (`recordDirsReadingAs`).
|
|
618
684
|
const readingAs = (model) => recordDirsReadingAs(recordsRoot, model, selfScope, { own, keyTypes })
|
|
619
|
-
const findRecordFile = (poolDir, model, ownId) =>
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
685
|
+
const findRecordFile = (poolDir, model, ownId) => {
|
|
686
|
+
const dirs = [poolDir, ...readingAs(model).filter((dir) => dir !== poolDir)]
|
|
687
|
+
// Single-record files first, as before; then an entry of a list file.
|
|
688
|
+
for (const find of [findRecordFileByUuid, findListEntryByUuid]) {
|
|
689
|
+
for (const dir of dirs) {
|
|
690
|
+
const hit = find(dir, ownId)
|
|
691
|
+
if (hit) return hit
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return null
|
|
695
|
+
}
|
|
696
|
+
// BibTeX files holding a pulled record — never rewritten by a pull, and said once each.
|
|
697
|
+
const keptBib = new Set()
|
|
626
698
|
|
|
627
699
|
for (const document of recordDocs) {
|
|
628
700
|
const where = locate(document, folderIndex)
|
|
@@ -677,9 +749,21 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
677
749
|
// ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
|
|
678
750
|
// backend must not overwrite the identity the record already has.
|
|
679
751
|
const toWrite = ownId && ownId !== theirs ? { ...document, $uuid: ownId } : document
|
|
752
|
+
// The record's identity as the build keys its translations — the folder its file is in,
|
|
753
|
+
// under `records/`, and its handle — so a translation that differs from one record to
|
|
754
|
+
// another comes back as the author's `{ default, overrides }`.
|
|
755
|
+
const context = { key: [relative(recordsRoot, dirname(existing?.path ?? filePath)).split(sep).join('/'), where.slug].filter(Boolean).join('/') }
|
|
680
756
|
let status
|
|
681
757
|
try {
|
|
682
|
-
|
|
758
|
+
if (existing?.list && existing.format === 'bib') {
|
|
759
|
+
// Kept as the author has it: a pull does not re-render BibTeX (said once per file, below).
|
|
760
|
+
keptBib.add(existing.path)
|
|
761
|
+
status = 'unchanged'
|
|
762
|
+
} else if (existing?.list) {
|
|
763
|
+
status = writeRecordIntoList({ list: existing, document: toWrite, declaration, sourceLocale, collector, freeformRelPath, refName, context })
|
|
764
|
+
} else {
|
|
765
|
+
status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
766
|
+
}
|
|
683
767
|
} catch (err) {
|
|
684
768
|
// ⛔ A record that could not be written was not placed, so it is a SKIP, not a
|
|
685
769
|
// warning: a caller that counts what it placed (the CLI's pull) must see it.
|
|
@@ -707,6 +791,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
707
791
|
// site's folder, keyed by the site-content uuid.
|
|
708
792
|
const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
|
|
709
793
|
warnings.push(...records.warnings)
|
|
794
|
+
for (const path of keptBib) {
|
|
795
|
+
warnings.push(`${relative(siteRoot, path)}: a pull does not rewrite a BibTeX file — its records are kept as they are.`)
|
|
796
|
+
}
|
|
710
797
|
|
|
711
798
|
// Flush localized record-field translations to locales/records/{locale}.json,
|
|
712
799
|
// and any prosemirror free-form body overrides to locales/freeform/{locale}/.
|
package/src/uwx/records.js
CHANGED
|
@@ -62,7 +62,7 @@ import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.j
|
|
|
62
62
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
63
63
|
import { isUuid } from './uuid.js'
|
|
64
64
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
65
|
-
import { LOCALIZED_FIELD_ASSUMPTION
|
|
65
|
+
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
66
66
|
import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
67
67
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
68
68
|
import { isDraftRecord } from '../site/record-draft.js'
|
|
@@ -164,7 +164,10 @@ export function entityContentHash(document) {
|
|
|
164
164
|
return sha256Hex(toJsonBuffer(stripSigils(document)))
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
// ⭐ `context` is the record the value is in — `{ key: '<pool>/<handle>' }`, the identity its
|
|
168
|
+
// translations are kept under (`i18n/records.js`) — so an entry that reads differently in some
|
|
169
|
+
// records (`{ default, overrides }`) is sent as each one renders it.
|
|
170
|
+
function encodeFieldValue(value, field, sourceLocale, translations, context = null) {
|
|
168
171
|
if (value == null) return value
|
|
169
172
|
if (isProseMirrorField(field)) {
|
|
170
173
|
// markdown source → ProseMirror doc. When localized, wrap per-locale exactly
|
|
@@ -172,7 +175,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
172
175
|
// path, flushed to locales/records/{locale}.json by the caller.
|
|
173
176
|
const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
|
|
174
177
|
if (!field.localized) return doc
|
|
175
|
-
const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
|
|
178
|
+
const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations, context)
|
|
176
179
|
// localizeContentDoc returns a BARE doc when there are no target locales. A
|
|
177
180
|
// localized field MUST ride as a `{ lang: value }` map on the wire — the
|
|
178
181
|
// schema-driven projector drops a localized field whose value isn't a map — so
|
|
@@ -184,15 +187,14 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
184
187
|
// element — the shape page `keywords` already take. ⛔ It went up as a bare list
|
|
185
188
|
// until 2026-09-24: `localizeScalar` passes any object through, arrays included.
|
|
186
189
|
if (Array.isArray(value) && !isMarkupTextField(field)) {
|
|
187
|
-
return localizeScalarList(value, sourceLocale, translations)
|
|
190
|
+
return localizeScalarList(value, sourceLocale, translations, context)
|
|
188
191
|
}
|
|
189
|
-
// A markup `text`
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
: localizeScalar(value, sourceLocale, translations)
|
|
192
|
+
// A markup `text` field (format markdown|html) rides as its RAW source, and its
|
|
193
|
+
// translation is the one the build renders: the whole value, keyed by the value
|
|
194
|
+
// (`i18n/records.js` extracts and translates it as one unit) — so it is wrapped
|
|
195
|
+
// per string like any localized scalar. ⛔ Until 2026-09-26 it went up as its
|
|
196
|
+
// source alone (`{ en }`), though the static build rendered its translation.
|
|
197
|
+
return localizeScalar(value, sourceLocale, translations, context)
|
|
196
198
|
}
|
|
197
199
|
// A Date handed in by a caller. The backend validates `date` as `YYYY-MM-DD` and
|
|
198
200
|
// `datetime` as RFC3339 — emitting full ISO for a `date` field is rejected before
|
|
@@ -291,14 +293,15 @@ export function recordsToEntities({
|
|
|
291
293
|
const uuid = record.$uuid || null
|
|
292
294
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
293
295
|
|
|
294
|
-
const
|
|
296
|
+
const context = { key: `${label}/${slug}` } // as the build keys the record's translations
|
|
297
|
+
const enc = encoder(sourceLocale, translations, refs, context)
|
|
295
298
|
const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
|
|
296
299
|
// The markdown body fills the content body field, in whatever section declares it,
|
|
297
300
|
// unless the file already sets that field.
|
|
298
301
|
if (hasBody && bodyTarget) {
|
|
299
302
|
const data = (sections[bodyTarget.section] ??= {})
|
|
300
303
|
if (data[bodyTarget.key] == null) {
|
|
301
|
-
data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
|
|
304
|
+
data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations, context)
|
|
302
305
|
}
|
|
303
306
|
}
|
|
304
307
|
// The brief is the card, and is always sent — an empty one included, so a required
|
|
@@ -381,8 +384,8 @@ export function recordsToEntities({
|
|
|
381
384
|
// What one record's read collects beside the values: the keys with no place in the
|
|
382
385
|
// schema, the keys written flat in a schema written by section, and values of the
|
|
383
386
|
// wrong shape for their section.
|
|
384
|
-
function encoder(sourceLocale, translations, refs = null) {
|
|
385
|
-
return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
|
|
387
|
+
function encoder(sourceLocale, translations, refs = null, context = null) {
|
|
388
|
+
return { sourceLocale, translations, refs, context, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
|
|
386
389
|
}
|
|
387
390
|
|
|
388
391
|
// A flat record: its keys, minus the record's own, are the one section's fields.
|
|
@@ -473,7 +476,7 @@ function encodeRecord(fields, value, enc, prefix, reserved) {
|
|
|
473
476
|
? encodeSection(field, v, enc, `${prefix}${key}`)
|
|
474
477
|
: field?.type === 'entity_ref'
|
|
475
478
|
? encodeReference(v, field, enc, `${prefix}${key}`)
|
|
476
|
-
: encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
|
|
479
|
+
: encodeFieldValue(v, field, enc.sourceLocale, enc.translations, enc.context)
|
|
477
480
|
if (encoded !== undefined) out[key] = encoded
|
|
478
481
|
}
|
|
479
482
|
for (const key of Object.keys(value)) {
|
|
@@ -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
|
+
}
|