@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
|
@@ -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,29 @@ 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
|
+
// ⛔ An EMPTY body is one too: until 2026-09-26 it never compared as unchanged, so a section holding
|
|
205
|
+
// only frontmatter — a parametric page's `type:` and `fetch:` — was re-serialized, and lost its
|
|
206
|
+
// comments, on a pull that changed nothing. So is no document at all (a params-only update).
|
|
207
|
+
const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
|
|
208
|
+
const keepsBody = !content || sameMarkdownDocument(authoredBody, content)
|
|
209
|
+
if (existing && keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
|
|
210
|
+
const body = keepsBody ? authoredBody : proseMirrorToMarkdown(content)
|
|
194
211
|
return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
|
|
195
212
|
}
|
|
196
213
|
|
|
214
|
+
|
|
197
215
|
// Shallow-merge `changes` into a YAML config file and write idempotently. A key
|
|
198
216
|
// whose value is null/undefined is deleted; an object value is shallow-merged one
|
|
199
217
|
// level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
|
|
@@ -202,12 +220,8 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
202
220
|
// config, but comment-preserving merges for hand-authored config files are a
|
|
203
221
|
// quality bar to revisit.
|
|
204
222
|
function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
existing = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
|
|
208
|
-
} catch {
|
|
209
|
-
// missing / invalid → start fresh
|
|
210
|
-
}
|
|
223
|
+
const prior = loadYamlFile(filePath)
|
|
224
|
+
const existing = isPlainObject(prior.value) ? { ...prior.value } : {}
|
|
211
225
|
for (const [key, value] of Object.entries(changes)) {
|
|
212
226
|
if (value === null || value === undefined) {
|
|
213
227
|
delete existing[key]
|
|
@@ -216,13 +230,32 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
216
230
|
// pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
|
|
217
231
|
// schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
|
|
218
232
|
// object has nothing to keep, so the incoming value replaces it.
|
|
219
|
-
const
|
|
220
|
-
existing[key] = isPlainObject(
|
|
233
|
+
const current = existing[key]
|
|
234
|
+
existing[key] = isPlainObject(current) ? { ...current, ...value } : value
|
|
221
235
|
} else {
|
|
222
236
|
existing[key] = value
|
|
223
237
|
}
|
|
224
238
|
}
|
|
225
|
-
return
|
|
239
|
+
return writeYamlIfChanged(filePath, existing, prior)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A YAML file, parsed — and whether there was one: a missing or unreadable file is never "unchanged".
|
|
243
|
+
function loadYamlFile(filePath) {
|
|
244
|
+
try {
|
|
245
|
+
return { value: yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) ?? {}, read: true }
|
|
246
|
+
} catch {
|
|
247
|
+
return { value: {}, read: false }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ⭐ A FILE WHOSE MEANING WOULD NOT CHANGE IS LEFT AS ITS AUTHOR WROTE IT. Every writer here re-dumps the
|
|
252
|
+
// whole file — its comments dropped, its strings re-quoted, its lists reflowed — so a pull that restated
|
|
253
|
+
// a file rewrote it all the same. ⛔ Until 2026-09-26 a pull into the copy that pushed it re-dumped
|
|
254
|
+
// `site.yml`, `theme.yml` and every `page.yml` (measured on the `international` template: every comment
|
|
255
|
+
// in its `site.yml` gone, and nothing had changed).
|
|
256
|
+
function writeYamlIfChanged(filePath, value, prior) {
|
|
257
|
+
if (prior.read && canonicalJson(value) === canonicalJson(prior.value)) return 'unchanged'
|
|
258
|
+
return writeIfChanged(filePath, yaml.dump(value, YAML_DUMP_OPTS))
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -249,7 +282,7 @@ export function writeSiteConfig(siteRoot, config) {
|
|
|
249
282
|
* @returns {'updated'|'unchanged'}
|
|
250
283
|
*/
|
|
251
284
|
export function writeYamlFile(filePath, obj) {
|
|
252
|
-
return
|
|
285
|
+
return writeYamlIfChanged(filePath, obj || {}, loadYamlFile(filePath))
|
|
253
286
|
}
|
|
254
287
|
|
|
255
288
|
/**
|
|
@@ -269,17 +302,17 @@ export function writeYamlFile(filePath, obj) {
|
|
|
269
302
|
* @returns {'updated'|'unchanged'}
|
|
270
303
|
*/
|
|
271
304
|
export function writeMergedYaml(filePath, projected, managedKeys) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
305
|
+
const prior = loadYamlFile(filePath)
|
|
306
|
+
const existing = isPlainObject(prior.value) ? prior.value : {}
|
|
307
|
+
const managed = new Set(managedKeys)
|
|
308
|
+
// Each key where the file has it — a value the pull changes stays in its place — then the new ones.
|
|
309
|
+
const out = {}
|
|
310
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
311
|
+
if (key in projected) out[key] = projected[key]
|
|
312
|
+
else if (!managed.has(key)) out[key] = value
|
|
277
313
|
}
|
|
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))
|
|
314
|
+
for (const [key, value] of Object.entries(projected)) if (!(key in out)) out[key] = value
|
|
315
|
+
return writeYamlIfChanged(filePath, out, prior)
|
|
283
316
|
}
|
|
284
317
|
|
|
285
318
|
/**
|
|
@@ -309,12 +342,31 @@ export function writeQueriesConfig(siteRoot, queries) {
|
|
|
309
342
|
// `events:` is `{}`), which a pull brings back in its long form. And a pull that
|
|
310
343
|
// restates every query writes nothing: the file is re-dumped whole otherwise, which
|
|
311
344
|
// rewrites a bare `events:` as `events: null` and flow style as block style.
|
|
345
|
+
//
|
|
346
|
+
// ⭐ WHEREVER THE AUTHOR DECLARED IT — `queries.yml`, or `site.yml`'s `queries:` block, which the
|
|
347
|
+
// build reads too, under it. ⛔ Until 2026-09-26 only `queries.yml` was consulted, so a site whose
|
|
348
|
+
// queries live in `site.yml` (the `international` template) got a `queries.yml` repeating every one
|
|
349
|
+
// of them on each pull, in the long form. A query that changed is written back where it lives.
|
|
350
|
+
let site = {}
|
|
351
|
+
try {
|
|
352
|
+
site = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
|
|
353
|
+
} catch {
|
|
354
|
+
// missing / invalid → nothing declared there
|
|
355
|
+
}
|
|
356
|
+
const inSite = isPlainObject(site.queries) ? site.queries : {}
|
|
312
357
|
const changes = {}
|
|
358
|
+
const siteChanges = {}
|
|
313
359
|
for (const [name, decl] of Object.entries(queries)) {
|
|
360
|
+
const inFile = isPlainObject(authored) && name in authored
|
|
361
|
+
if (!inFile && name in inSite) {
|
|
362
|
+
if (!restates(inSite[name], decl)) siteChanges[name] = decl
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
314
365
|
if (restates(authored?.[name], decl)) continue
|
|
315
366
|
changes[name] = decl
|
|
316
367
|
}
|
|
317
|
-
if (Object.keys(
|
|
368
|
+
if (Object.keys(siteChanges).length) writeSiteConfig(siteRoot, { queries: siteChanges })
|
|
369
|
+
if (Object.keys(changes).length === 0) return Object.keys(siteChanges).length ? 'updated' : 'unchanged'
|
|
318
370
|
// ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
|
|
319
371
|
// the local one — what the call did until 2026-09-23 — a key the backend's
|
|
320
372
|
// declaration no longer has survived every pull: the defect `writeSiteConfig`
|
|
@@ -330,11 +382,6 @@ function restates(authored, decl) {
|
|
|
330
382
|
return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
|
|
331
383
|
}
|
|
332
384
|
|
|
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
385
|
|
|
339
386
|
/**
|
|
340
387
|
* Write the records folder's organization — `folder.yml` in the records directory
|
|
@@ -382,9 +429,40 @@ export function writeThemeFile(siteRoot, theme) {
|
|
|
382
429
|
* body (so a target-locale full-doc body is captured for locales/freeform/)
|
|
383
430
|
* @param {(model: string, uuid: string) => string|null} [opts.refName] - the name a
|
|
384
431
|
* referenced record goes by, so a reference is written as that name
|
|
432
|
+
* @param {{ key: string }} [opts.context] - the record's identity, `<pool>/<handle>`, which
|
|
433
|
+
* its translations are kept under — an entry that differs from record to record
|
|
434
|
+
* comes back as `{ default, overrides }`
|
|
385
435
|
* @returns {'updated'|'unchanged'}
|
|
386
436
|
*/
|
|
387
|
-
export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
|
|
388
|
-
const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName })
|
|
437
|
+
export function writeRecordFile({ filePath, document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
|
|
438
|
+
const text = renderEntityDocument({ document, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
439
|
+
// ⭐ A record the pull did not change keeps the author's file — its key order, its blank lines, its
|
|
440
|
+
// quoting. ⛔ Until 2026-09-26 each one was re-rendered (measured on the `international` template).
|
|
441
|
+
let current = null
|
|
442
|
+
try {
|
|
443
|
+
current = readFileSync(filePath, 'utf8')
|
|
444
|
+
} catch {
|
|
445
|
+
// new file
|
|
446
|
+
}
|
|
447
|
+
if (current !== null && sameRecordText(current, text, format, filePath)) return 'unchanged'
|
|
389
448
|
return writeIfChanged(filePath, text)
|
|
390
449
|
}
|
|
450
|
+
|
|
451
|
+
// Do two renderings of a record file say the same thing — the same data, and a body that parses to
|
|
452
|
+
// the same document?
|
|
453
|
+
function sameRecordText(a, b, format, filePath) {
|
|
454
|
+
try {
|
|
455
|
+
if (format === 'md') {
|
|
456
|
+
const x = parseFrontmatter(a, filePath)
|
|
457
|
+
const y = parseFrontmatter(b, filePath)
|
|
458
|
+
if (canonicalJson(x.frontmatter) !== canonicalJson(y.frontmatter)) return false
|
|
459
|
+
const bx = (x.body || '').trim()
|
|
460
|
+
const by = (y.body || '').trim()
|
|
461
|
+
return bx === by || sameMarkdownDocument(bx, markdownToProseMirror(by))
|
|
462
|
+
}
|
|
463
|
+
const parse = format === 'json' ? JSON.parse : (t) => yaml.load(t, YAML_OPTIONS)
|
|
464
|
+
return canonicalJson(parse(a)) === canonicalJson(parse(b))
|
|
465
|
+
} catch {
|
|
466
|
+
return false
|
|
467
|
+
}
|
|
468
|
+
}
|
|
@@ -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.
|
|
@@ -205,8 +255,12 @@ function siteFoundationDeclarations(siteRoot) {
|
|
|
205
255
|
return { own: null, keyTypes: null }
|
|
206
256
|
}
|
|
207
257
|
const schema = siteYml ? foundationSchemaJson(siteRoot, siteYml) : null
|
|
258
|
+
// ⛔ Own names only from a schema that carries its data schemas. The registered version a clone
|
|
259
|
+
// keeps carries none (they travel as Models), and read as an empty set it said "owns nothing", so a
|
|
260
|
+
// clone placed a foundation's own `@std/exhibit` in `records/std/exhibit/`; unknown (null), the
|
|
261
|
+
// standard-schema rule decides (`unresolveSelfScope`), as it did before the kept version existed.
|
|
208
262
|
return schema
|
|
209
|
-
? { own: ownSchemaNames(schema.dataSchemas
|
|
263
|
+
? { own: ownSchemaNames(schema.dataSchemas), keyTypes: dataKeyTypes(schema) }
|
|
210
264
|
: { own: null, keyTypes: null }
|
|
211
265
|
}
|
|
212
266
|
|
|
@@ -268,22 +322,39 @@ const DECL_WIRE_CONSUMED = new Set([
|
|
|
268
322
|
'deferred',
|
|
269
323
|
'detail_url',
|
|
270
324
|
'detail',
|
|
271
|
-
'queryable'
|
|
325
|
+
'queryable',
|
|
326
|
+
// Says the query names no schema of its own — how the file is written, never a key in it.
|
|
327
|
+
'typed_by_data_key',
|
|
272
328
|
])
|
|
273
329
|
|
|
330
|
+
// A standard schema (`@std/<name>`), normalized as the build resolves one — known with no foundation
|
|
331
|
+
// on disk. ⛔ Until 2026-09-26 a clone, which has none, could not tell a derived `deferred:` from an
|
|
332
|
+
// authored one and wrote it into the author's queries (`international`'s `articles`, over `@std/article`).
|
|
333
|
+
function standardDataSchema(ref) {
|
|
334
|
+
const m = typeof ref === 'string' ? /^@std\/([^/]+)$/.exec(ref) : null
|
|
335
|
+
if (!m || !isStandardSchema(m[1])) return null
|
|
336
|
+
try {
|
|
337
|
+
return validateAndNormalizeSchema(getStandardSchema(m[1]), ref)
|
|
338
|
+
} catch {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
274
343
|
// Is this wire `deferred` exactly what the schema's brief would have derived? Compared
|
|
275
344
|
// as an ORDER-INSENSITIVE set: the deriver walks the schema's sections, and a round trip
|
|
276
345
|
// through YAML and the store is not obliged to preserve that order. Comparing as a list
|
|
277
346
|
// would classify a reordered-but-identical value as authored, and persist it.
|
|
278
|
-
function isDerivedDeferred(d, dataSchemas) {
|
|
279
|
-
if (!
|
|
280
|
-
|
|
347
|
+
function isDerivedDeferred(d, dataSchemas, pulledModel = null) {
|
|
348
|
+
if (!Array.isArray(d.deferred)) return false
|
|
349
|
+
// A clone has no foundation to read a schema from: it judges by the Model the pull read from the
|
|
350
|
+
// backend (`pulledModel`), and a standard schema is known in any case (`standardDataSchema`).
|
|
351
|
+
const derived = deferredFromSchema(dataSchemas?.[d.schema] ?? pulledModel ?? standardDataSchema(d.schema))
|
|
281
352
|
if (!derived || derived.length !== d.deferred.length) return false
|
|
282
353
|
const a = new Set(derived)
|
|
283
354
|
return d.deferred.every((f) => a.has(f))
|
|
284
355
|
}
|
|
285
356
|
|
|
286
|
-
function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null) {
|
|
357
|
+
function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null, markedOnly = false) {
|
|
287
358
|
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
288
359
|
// `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
|
|
289
360
|
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
@@ -320,8 +391,15 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
|
|
|
320
391
|
// ⛔ UNLESS THE AUTHOR WROTE IT: a `schema:` the local file already states for this query is
|
|
321
392
|
// written back as it is. Until 2026-09-25 a pull into a working copy dropped international's
|
|
322
393
|
// `articles: { schema: '@std/article' }`, because a section types `articles` as `@std/article`.
|
|
323
|
-
|
|
394
|
+
// ⭐ A CLONE GOES BY THE MARK. Where the key's type comes only from the registered foundation a
|
|
395
|
+
// project keeps (`markedOnly`: its foundation is a catalog ref), a query is written terse only when
|
|
396
|
+
// the push marked it `typed_by_data_key` — the author wrote no schema — or when the author's own
|
|
397
|
+
// file declares it with none. Unmarked, the schema was the author's, and it is written.
|
|
324
398
|
const written = name ? authored?.get(name) : undefined
|
|
399
|
+
const typedHere = !markedOnly || wire.typed_by_data_key === true || written === null
|
|
400
|
+
const keyType = name && !own?.has(name) && typedHere ? keyTypes?.get(name) : null
|
|
401
|
+
// In the author's form: the registered version names the type qualified (`@acme/member`).
|
|
402
|
+
const keyDefault = keyType ? unresolveSelfScope(keyType, scope, own) : null
|
|
325
403
|
if (d.schema && (d.schema === written || (d.schema !== defaultSchema(name) && d.schema !== keyDefault))) {
|
|
326
404
|
decl.schema = d.schema
|
|
327
405
|
}
|
|
@@ -346,7 +424,7 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
|
|
|
346
424
|
//
|
|
347
425
|
// ⚖️ Only an EQUAL value is dropped. An author who deliberately writes a narrower or
|
|
348
426
|
// wider `deferred:` than the brief implies has expressed intent, and that survives.
|
|
349
|
-
if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas)) {
|
|
427
|
+
if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas, models?.[wire.schema] ?? null)) {
|
|
350
428
|
decl.deferred = d.deferred
|
|
351
429
|
}
|
|
352
430
|
// ⛔ A stored `detail_url` is not written back: `detailUrl:` is retired (2026-09-13)
|
|
@@ -398,18 +476,42 @@ function authoredQuerySchemas(siteRoot, siteYml) {
|
|
|
398
476
|
} catch {
|
|
399
477
|
file = null
|
|
400
478
|
}
|
|
479
|
+
// name → the schema the author's file states for it, or null for a query it declares with none.
|
|
480
|
+
// ⛔ The string shorthand (`team: '@/member'`) is a schema too: until 2026-09-26 it read as none,
|
|
481
|
+
// so a pull could write the author's `team: '@/member'` back as `team: {}`.
|
|
401
482
|
const out = new Map()
|
|
402
483
|
for (const decls of [siteYml?.queries, file]) {
|
|
403
484
|
if (!decls || typeof decls !== 'object') continue
|
|
404
485
|
for (const [name, decl] of Object.entries(decls)) {
|
|
405
|
-
|
|
406
|
-
|
|
486
|
+
const schema = typeof decl === 'string' ? decl : typeof decl?.schema === 'string' ? decl.schema : null
|
|
487
|
+
out.set(name, schema)
|
|
407
488
|
}
|
|
408
489
|
}
|
|
409
490
|
return out
|
|
410
491
|
}
|
|
411
492
|
|
|
412
|
-
|
|
493
|
+
// ⭐ WHERE A NEW RECORD OF A KEY'S TYPE GOES: the folder of a query the site declares with no schema
|
|
494
|
+
// of its own, whose name is a data key typed as that Model — `records/team/` for `team:` typed
|
|
495
|
+
// `@acme/member` — which is where a push reads it from (`uwx/data-key-types.js`). model → folder.
|
|
496
|
+
function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
|
|
497
|
+
const out = new Map()
|
|
498
|
+
if (!keyTypes?.size) return out
|
|
499
|
+
let siteYml = null
|
|
500
|
+
try {
|
|
501
|
+
siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
|
|
502
|
+
} catch {
|
|
503
|
+
siteYml = null
|
|
504
|
+
}
|
|
505
|
+
for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
|
|
506
|
+
if (schema !== null || own?.has(name)) continue
|
|
507
|
+
const type = keyTypes.get(name)
|
|
508
|
+
const model = type ? resolveSelfScope(type, scope) : null
|
|
509
|
+
if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
|
|
510
|
+
}
|
|
511
|
+
return out
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function declarationsToQueriesYml({ document, siteRoot, scope, models = null, ...rest }) {
|
|
413
515
|
refuseOrgOption(rest, 'declarationsToQueriesYml')
|
|
414
516
|
const decls = Array.isArray(document?.queries) ? document.queries : []
|
|
415
517
|
const report = {}
|
|
@@ -435,10 +537,12 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
435
537
|
const own = ownSchemaNames(dataSchemas)
|
|
436
538
|
const keyTypes = siteYml ? dataKeyTypes(foundationSchemaJson(siteRoot, siteYml)) : null
|
|
437
539
|
const authored = authoredQuerySchemas(siteRoot, siteYml)
|
|
540
|
+
// The key types a catalog-ref foundation has come from the registered version the project keeps.
|
|
541
|
+
const markedOnly = Boolean(parseCatalogRef(siteYml?.foundation))
|
|
438
542
|
|
|
439
543
|
const queries = {}
|
|
440
544
|
for (const d of decls) {
|
|
441
|
-
const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored)
|
|
545
|
+
const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored, models, markedOnly)
|
|
442
546
|
if (!name) continue
|
|
443
547
|
queries[name] = decl
|
|
444
548
|
}
|
|
@@ -524,7 +628,8 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
524
628
|
unplaceable = true
|
|
525
629
|
continue
|
|
526
630
|
}
|
|
527
|
-
|
|
631
|
+
// A list file holds several records and is one path — listed once.
|
|
632
|
+
if (!out.includes(rel)) out.push(rel)
|
|
528
633
|
}
|
|
529
634
|
return out
|
|
530
635
|
}
|
|
@@ -589,7 +694,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
589
694
|
// Captures target-locale translations of localized record fields: SCALARs →
|
|
590
695
|
// locales/records/{locale}.json (structural maps too), and a prosemirror
|
|
591
696
|
// BODY's free-form per-locale override → locales/freeform/{locale}/records/.
|
|
592
|
-
const collector = createTranslationCollector(sourceLocale)
|
|
697
|
+
const collector = createTranslationCollector(sourceLocale, { siteRoot })
|
|
593
698
|
const updated = []
|
|
594
699
|
const placed = []
|
|
595
700
|
const unchanged = []
|
|
@@ -616,13 +721,20 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
616
721
|
// The file holding a record already, by its own id: the derived folder first, then any
|
|
617
722
|
// other that reads as the same Model (`recordDirsReadingAs`).
|
|
618
723
|
const readingAs = (model) => recordDirsReadingAs(recordsRoot, model, selfScope, { own, keyTypes })
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
readingAs(model)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
724
|
+
const keyFolders = typedQueryFolders(siteRoot, recordsRoot, selfScope, own, keyTypes)
|
|
725
|
+
const findRecordFile = (poolDir, model, ownId) => {
|
|
726
|
+
const dirs = [poolDir, ...readingAs(model).filter((dir) => dir !== poolDir)]
|
|
727
|
+
// Single-record files first, as before; then an entry of a list file.
|
|
728
|
+
for (const find of [findRecordFileByUuid, findListEntryByUuid]) {
|
|
729
|
+
for (const dir of dirs) {
|
|
730
|
+
const hit = find(dir, ownId)
|
|
731
|
+
if (hit) return hit
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return null
|
|
735
|
+
}
|
|
736
|
+
// BibTeX files holding a pulled record — never rewritten by a pull, and said once each.
|
|
737
|
+
const keptBib = new Set()
|
|
626
738
|
|
|
627
739
|
for (const document of recordDocs) {
|
|
628
740
|
const where = locate(document, folderIndex)
|
|
@@ -663,8 +775,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
663
775
|
isNew = false
|
|
664
776
|
} else {
|
|
665
777
|
// A new record goes where the project already keeps records of its Model — its derived
|
|
666
|
-
// folder, else another that reads as the Model (`records/team/` for `@std/member`)
|
|
667
|
-
|
|
778
|
+
// folder, else another that reads as the Model (`records/team/` for `@std/member`), else the
|
|
779
|
+
// folder of a query typed by its data key as the Model (`typedQueryFolders`) — a clone's.
|
|
780
|
+
const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || keyFolders.get(schema) || poolDir
|
|
668
781
|
format = defaultFormat(home, declaration)
|
|
669
782
|
filePath = join(home, where.slug + EXT_FOR_FORMAT[format])
|
|
670
783
|
isNew = true
|
|
@@ -677,9 +790,21 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
677
790
|
// ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
|
|
678
791
|
// backend must not overwrite the identity the record already has.
|
|
679
792
|
const toWrite = ownId && ownId !== theirs ? { ...document, $uuid: ownId } : document
|
|
793
|
+
// The record's identity as the build keys its translations — the folder its file is in,
|
|
794
|
+
// under `records/`, and its handle — so a translation that differs from one record to
|
|
795
|
+
// another comes back as the author's `{ default, overrides }`.
|
|
796
|
+
const context = { key: [relative(recordsRoot, dirname(existing?.path ?? filePath)).split(sep).join('/'), where.slug].filter(Boolean).join('/') }
|
|
680
797
|
let status
|
|
681
798
|
try {
|
|
682
|
-
|
|
799
|
+
if (existing?.list && existing.format === 'bib') {
|
|
800
|
+
// Kept as the author has it: a pull does not re-render BibTeX (said once per file, below).
|
|
801
|
+
keptBib.add(existing.path)
|
|
802
|
+
status = 'unchanged'
|
|
803
|
+
} else if (existing?.list) {
|
|
804
|
+
status = writeRecordIntoList({ list: existing, document: toWrite, declaration, sourceLocale, collector, freeformRelPath, refName, context })
|
|
805
|
+
} else {
|
|
806
|
+
status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath, refName, context })
|
|
807
|
+
}
|
|
683
808
|
} catch (err) {
|
|
684
809
|
// ⛔ A record that could not be written was not placed, so it is a SKIP, not a
|
|
685
810
|
// warning: a caller that counts what it placed (the CLI's pull) must see it.
|
|
@@ -707,6 +832,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
707
832
|
// site's folder, keyed by the site-content uuid.
|
|
708
833
|
const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
|
|
709
834
|
warnings.push(...records.warnings)
|
|
835
|
+
for (const path of keptBib) {
|
|
836
|
+
warnings.push(`${relative(siteRoot, path)}: a pull does not rewrite a BibTeX file — its records are kept as they are.`)
|
|
837
|
+
}
|
|
710
838
|
|
|
711
839
|
// Flush localized record-field translations to locales/records/{locale}.json,
|
|
712
840
|
// and any prosemirror free-form body overrides to locales/freeform/{locale}/.
|