@uniweb/build 0.61.0 → 0.63.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 +5 -5
- package/src/i18n/freeform.js +3 -1
- package/src/i18n/records.js +161 -80
- package/src/resolve-data-schema.js +11 -1
- package/src/runtime-schema.js +14 -16
- package/src/site/queries-config.js +68 -13
- package/src/site/query-processor.js +310 -67
- package/src/uwx/backfill.js +115 -52
- package/src/uwx/data-schema.js +16 -11
- package/src/uwx/folder.js +15 -8
- package/src/uwx/project-writer.js +4 -2
- package/src/uwx/record-layout.js +61 -0
- package/src/uwx/records-project.js +23 -18
- package/src/uwx/records.js +387 -212
- package/src/uwx/registry-package.js +53 -4
- package/src/uwx/sync-package.js +18 -2
- package/src/uwx/uuid.js +7 -0
- package/src/validate-data.js +57 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,11 +58,11 @@
|
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
61
|
-
"@uniweb/
|
|
62
|
-
"@uniweb/schemas": "^0.
|
|
63
|
-
"@uniweb/theming": "^0.1.15",
|
|
61
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
62
|
+
"@uniweb/schemas": "^0.7.0",
|
|
64
63
|
"@uniweb/projections": "^0.7.3",
|
|
65
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
65
|
+
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
68
|
"@uniweb/runtime": "^0.27.4"
|
package/src/i18n/freeform.js
CHANGED
|
@@ -117,7 +117,7 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
|
|
|
117
117
|
* @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
|
|
118
118
|
* @param {string} locale - Locale code
|
|
119
119
|
* @param {string} localesDir - Path to locales directory
|
|
120
|
-
* @returns {Promise<Object|null>} Parsed translation { frontmatter, content } or null
|
|
120
|
+
* @returns {Promise<Object|null>} Parsed translation { frontmatter, content, markdown } or null
|
|
121
121
|
*/
|
|
122
122
|
export async function loadFreeformRecord(item, schema, locale, localesDir) {
|
|
123
123
|
const slug = item.slug
|
|
@@ -142,6 +142,8 @@ export async function loadFreeformRecord(item, schema, locale, localesDir) {
|
|
|
142
142
|
return {
|
|
143
143
|
frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null,
|
|
144
144
|
content: proseMirrorContent,
|
|
145
|
+
// The body's markdown source — what a markup `text` body field holds.
|
|
146
|
+
markdown: proseMirrorContent ? body : null,
|
|
145
147
|
filePath,
|
|
146
148
|
relativePath: relative(join(localesDir, 'freeform', locale), filePath)
|
|
147
149
|
}
|
package/src/i18n/records.js
CHANGED
|
@@ -22,7 +22,9 @@ import { loadFreeformRecord } from './freeform.js'
|
|
|
22
22
|
// answer for a tagged data block's payload — a `label` is prose and an `href`
|
|
23
23
|
// is not, wherever the value came from. Moved rather than copied: two tuned
|
|
24
24
|
// denylists would drift, and drift here is silent.
|
|
25
|
-
import { resolveQueriesConfig } from '../site/queries-config.js'
|
|
25
|
+
import { resolveQueriesConfig, resolveRecordSchemas } from '../site/queries-config.js'
|
|
26
|
+
import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
|
|
27
|
+
import { flatFormRefusal } from '../site/query-processor.js'
|
|
26
28
|
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
27
29
|
import {
|
|
28
30
|
NON_TRANSLATABLE_TYPES,
|
|
@@ -171,6 +173,57 @@ function isRecordSystemField(key, topLevel) {
|
|
|
171
173
|
return key.startsWith('$') || (topLevel && (key === 'slug' || key === 'path'))
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* A record's handle — `$name`, which the build sets from the record's file (`slug:` or
|
|
178
|
+
* its name). ⭐ Not `slug` alone: a record of a schema whose brief declares a `slug`
|
|
179
|
+
* FIELD (`@std/article`) is delivered with that field at the top, and it need not be
|
|
180
|
+
* the file's name, which is what the free-form tree and a translation's context use.
|
|
181
|
+
*/
|
|
182
|
+
function recordHandle(item) {
|
|
183
|
+
return item.$name ?? item.slug ?? item.id ?? item.name ?? 'unknown'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Is this value a ProseMirror document? */
|
|
187
|
+
function isProseMirrorDoc(value) {
|
|
188
|
+
return !!value && typeof value === 'object' && value.type === 'doc' && Array.isArray(value.content)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Every ProseMirror document a record holds, with its path. ⭐ A markdown body is
|
|
193
|
+
* delivered in its schema's content body field — `article_body.content` for
|
|
194
|
+
* `@std/article` — and at `content` only for a record with no schema; a rich field can
|
|
195
|
+
* sit anywhere else. ⛔ Until 2026-09-24 only `content` at the top was read, which is
|
|
196
|
+
* where a delivered `@std/article` holds no body.
|
|
197
|
+
*
|
|
198
|
+
* @returns {Array<{ path: string, doc: Object }>}
|
|
199
|
+
*/
|
|
200
|
+
function proseMirrorDocs(value, path = '', out = [], depth = 0) {
|
|
201
|
+
if (!value || typeof value !== 'object' || depth > MAX_HEURISTIC_DEPTH) return out
|
|
202
|
+
if (isProseMirrorDoc(value)) {
|
|
203
|
+
out.push({ path, doc: value })
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
const list = Array.isArray(value)
|
|
207
|
+
for (const [key, v] of list ? value.map((x, i) => [i, x]) : Object.entries(value)) {
|
|
208
|
+
if (!list && isRecordSystemField(key, depth === 0)) continue
|
|
209
|
+
const at = list ? `${path}[${key}]` : path ? `${path}.${key}` : key
|
|
210
|
+
proseMirrorDocs(v, at, out, depth + 1)
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The record with every ProseMirror document it holds translated — the rest copied as is. */
|
|
216
|
+
function translateDocs(value, context, translations, depth = 0) {
|
|
217
|
+
if (!value || typeof value !== 'object' || depth > MAX_HEURISTIC_DEPTH) return value
|
|
218
|
+
if (isProseMirrorDoc(value)) return translateProseMirrorDoc(value, context, translations)
|
|
219
|
+
if (Array.isArray(value)) return value.map((v) => translateDocs(v, context, translations, depth + 1))
|
|
220
|
+
const out = {}
|
|
221
|
+
for (const [key, v] of Object.entries(value)) {
|
|
222
|
+
out[key] = isRecordSystemField(key, depth === 0) ? v : translateDocs(v, context, translations, depth + 1)
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
174
227
|
// ---------------------------------------------------------------------------
|
|
175
228
|
// Schema-guided extraction
|
|
176
229
|
// ---------------------------------------------------------------------------
|
|
@@ -184,15 +237,12 @@ function isRecordSystemField(key, topLevel) {
|
|
|
184
237
|
* @param {Object} units - Accumulator
|
|
185
238
|
*/
|
|
186
239
|
function extractWithSchema(item, schema, recordDir, units) {
|
|
187
|
-
const
|
|
188
|
-
const context = { record: `${recordDir}/${slug}` }
|
|
240
|
+
const context = { record: `${recordDir}/${recordHandle(item)}` }
|
|
189
241
|
|
|
190
242
|
extractFromItemWithSchema(item, schema.fields, '', context, units)
|
|
191
243
|
|
|
192
|
-
// Also extract ProseMirror
|
|
193
|
-
|
|
194
|
-
extractFromProseMirrorDoc(item.content, context, units)
|
|
195
|
-
}
|
|
244
|
+
// Also extract the record's ProseMirror documents (not covered by schema fields)
|
|
245
|
+
for (const { path, doc } of proseMirrorDocs(item)) extractFromProseMirrorDoc(doc, context, units, path)
|
|
196
246
|
}
|
|
197
247
|
|
|
198
248
|
/**
|
|
@@ -247,15 +297,12 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
|
|
|
247
297
|
* Recursively walks the data, extracting strings that look like human-readable text.
|
|
248
298
|
*/
|
|
249
299
|
function extractHeuristic(item, recordDir, units) {
|
|
250
|
-
const
|
|
251
|
-
const context = { record: `${recordDir}/${slug}` }
|
|
300
|
+
const context = { record: `${recordDir}/${recordHandle(item)}` }
|
|
252
301
|
|
|
253
302
|
extractFromItemHeuristic(item, '', context, units, 0)
|
|
254
303
|
|
|
255
|
-
// Also extract ProseMirror
|
|
256
|
-
|
|
257
|
-
extractFromProseMirrorDoc(item.content, context, units)
|
|
258
|
-
}
|
|
304
|
+
// Also extract the record's ProseMirror documents, wherever it holds them
|
|
305
|
+
for (const { path, doc } of proseMirrorDocs(item)) extractFromProseMirrorDoc(doc, context, units, path)
|
|
259
306
|
}
|
|
260
307
|
|
|
261
308
|
/**
|
|
@@ -277,8 +324,8 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
|
|
|
277
324
|
if (value === undefined || value === null) continue
|
|
278
325
|
if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
|
|
279
326
|
|
|
280
|
-
// Skip ProseMirror
|
|
281
|
-
if (
|
|
327
|
+
// Skip a ProseMirror document (handled separately)
|
|
328
|
+
if (isProseMirrorDoc(value)) continue
|
|
282
329
|
|
|
283
330
|
if (typeof value === 'string') {
|
|
284
331
|
// Skip known structural field names
|
|
@@ -306,15 +353,9 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
|
|
|
306
353
|
/**
|
|
307
354
|
* Translate item fields using schema guidance.
|
|
308
355
|
*/
|
|
309
|
-
function translateWithSchema(item, schema, context, translations
|
|
310
|
-
const translated =
|
|
356
|
+
function translateWithSchema(item, schema, context, translations) {
|
|
357
|
+
const translated = translateDocs(item, context, translations)
|
|
311
358
|
translateItemWithSchema(translated, schema.fields, context, translations)
|
|
312
|
-
|
|
313
|
-
// Translate ProseMirror content
|
|
314
|
-
if (includeContent && translated.content?.type === 'doc') {
|
|
315
|
-
translated.content = translateProseMirrorDoc(translated.content, context, translations)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
359
|
return translated
|
|
319
360
|
}
|
|
320
361
|
|
|
@@ -364,14 +405,9 @@ function translateItemWithSchema(data, fields, context, translations, topLevel =
|
|
|
364
405
|
/**
|
|
365
406
|
* Translate item fields using heuristics.
|
|
366
407
|
*/
|
|
367
|
-
function translateHeuristic(item, context, translations
|
|
368
|
-
const translated =
|
|
408
|
+
function translateHeuristic(item, context, translations) {
|
|
409
|
+
const translated = translateDocs(item, context, translations)
|
|
369
410
|
translateItemHeuristic(translated, context, translations, 0)
|
|
370
|
-
|
|
371
|
-
if (includeContent && translated.content?.type === 'doc') {
|
|
372
|
-
translated.content = translateProseMirrorDoc(translated.content, context, translations)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
411
|
return translated
|
|
376
412
|
}
|
|
377
413
|
|
|
@@ -390,8 +426,8 @@ function translateItemHeuristic(data, context, translations, depth) {
|
|
|
390
426
|
if (value === undefined || value === null) continue
|
|
391
427
|
if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
|
|
392
428
|
|
|
393
|
-
// Skip ProseMirror
|
|
394
|
-
if (
|
|
429
|
+
// Skip a ProseMirror document (translated separately)
|
|
430
|
+
if (isProseMirrorDoc(value)) continue
|
|
395
431
|
|
|
396
432
|
if (typeof value === 'string') {
|
|
397
433
|
if (!Array.isArray(data) && HEURISTIC_SKIP_FIELDS.has(key)) continue
|
|
@@ -423,18 +459,34 @@ function translateItemHeuristic(data, context, translations, depth) {
|
|
|
423
459
|
* where there is no record on disk to be identified.
|
|
424
460
|
*/
|
|
425
461
|
async function poolDirsByQuery(siteRoot) {
|
|
426
|
-
|
|
462
|
+
return (await recordQueries(siteRoot)).poolDirs
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Each query's pool directory (`poolDirsByQuery`) and the data schema its records are
|
|
467
|
+
* delivered in (`resolveRecordSchemas`, null for none) — what a free-form translation
|
|
468
|
+
* needs to put a body and frontmatter where the delivered record holds them.
|
|
469
|
+
*
|
|
470
|
+
* @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null> }>}
|
|
471
|
+
*/
|
|
472
|
+
async function recordQueries(siteRoot) {
|
|
473
|
+
const poolDirs = new Map()
|
|
474
|
+
const schemas = new Map()
|
|
427
475
|
try {
|
|
428
476
|
const { declarations } = await resolveQueriesConfig(siteRoot)
|
|
429
|
-
|
|
477
|
+
const entries = Object.entries(declarations || {})
|
|
478
|
+
for (const [name, decl] of entries) {
|
|
430
479
|
const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
|
|
431
|
-
|
|
480
|
+
poolDirs.set(name, dirs ? dirs.join('/') : name)
|
|
432
481
|
}
|
|
482
|
+
const refs = entries.filter(([, d]) => d.url === undefined && d.schema).map(([, d]) => d.schema)
|
|
483
|
+
const resolved = (await resolveRecordSchemas(siteRoot, refs)).schemas
|
|
484
|
+
for (const [name, decl] of entries) schemas.set(name, (decl.schema && resolved[decl.schema]) || null)
|
|
433
485
|
} catch {
|
|
434
486
|
// No resolvable config — every record keys by its query name, which is what
|
|
435
487
|
// the extractor did before records existed.
|
|
436
488
|
}
|
|
437
|
-
return
|
|
489
|
+
return { poolDirs, schemas }
|
|
438
490
|
}
|
|
439
491
|
|
|
440
492
|
|
|
@@ -558,9 +610,10 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
|
|
|
558
610
|
// ---------------------------------------------------------------------------
|
|
559
611
|
|
|
560
612
|
/**
|
|
561
|
-
* Extract from ProseMirror document
|
|
613
|
+
* Extract from a ProseMirror document — `field` is where the record holds it
|
|
614
|
+
* (`content`, `article_body.content`), which each unit's `field` starts with.
|
|
562
615
|
*/
|
|
563
|
-
function extractFromProseMirrorDoc(doc, context, units) {
|
|
616
|
+
function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
|
|
564
617
|
if (!doc.content) return
|
|
565
618
|
|
|
566
619
|
let headingIndex = 0
|
|
@@ -570,17 +623,17 @@ function extractFromProseMirrorDoc(doc, context, units) {
|
|
|
570
623
|
if (node.type === 'heading') {
|
|
571
624
|
const text = extractTextFromNode(node)
|
|
572
625
|
if (text) {
|
|
573
|
-
addUnit(units, text,
|
|
626
|
+
addUnit(units, text, `${field}.heading.${headingIndex}`, context)
|
|
574
627
|
headingIndex++
|
|
575
628
|
}
|
|
576
629
|
} else if (node.type === 'paragraph') {
|
|
577
630
|
const text = extractTextFromNode(node)
|
|
578
631
|
if (text) {
|
|
579
|
-
addUnit(units, text,
|
|
632
|
+
addUnit(units, text, `${field}.paragraph.${paragraphIndex}`, context)
|
|
580
633
|
paragraphIndex++
|
|
581
634
|
}
|
|
582
635
|
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
|
583
|
-
extractFromList(node, context, units)
|
|
636
|
+
extractFromList(node, context, units, field)
|
|
584
637
|
}
|
|
585
638
|
}
|
|
586
639
|
}
|
|
@@ -588,7 +641,7 @@ function extractFromProseMirrorDoc(doc, context, units) {
|
|
|
588
641
|
/**
|
|
589
642
|
* Extract from list nodes
|
|
590
643
|
*/
|
|
591
|
-
function extractFromList(listNode, context, units) {
|
|
644
|
+
function extractFromList(listNode, context, units, field = 'content') {
|
|
592
645
|
if (!listNode.content) return
|
|
593
646
|
|
|
594
647
|
listNode.content.forEach((listItem, index) => {
|
|
@@ -597,7 +650,7 @@ function extractFromList(listNode, context, units) {
|
|
|
597
650
|
if (child.type === 'paragraph') {
|
|
598
651
|
const text = extractTextFromNode(child)
|
|
599
652
|
if (text) {
|
|
600
|
-
addUnit(units, text,
|
|
653
|
+
addUnit(units, text, `${field}.list.${index}`, context)
|
|
601
654
|
}
|
|
602
655
|
}
|
|
603
656
|
}
|
|
@@ -690,7 +743,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
690
743
|
// simply miss and every string falls back to its source. (This was missing for
|
|
691
744
|
// a while and the failure was swallowed by the per-file catch below — the build
|
|
692
745
|
// stayed green while nothing was translated.)
|
|
693
|
-
const poolDirs = await
|
|
746
|
+
const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
|
|
694
747
|
|
|
695
748
|
const outputs = {}
|
|
696
749
|
// Reported rather than only logged — see the catch below.
|
|
@@ -737,6 +790,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
737
790
|
// Resolve schema once per collection
|
|
738
791
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
739
792
|
const recordDir = poolDirs.get(queryName) ?? queryName
|
|
793
|
+
const dataSchema = dataSchemas.get(queryName) ?? null
|
|
740
794
|
|
|
741
795
|
// Translate each item (with free-form support)
|
|
742
796
|
const translatedItems = await Promise.all(
|
|
@@ -744,7 +798,8 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
744
798
|
translateItemAsync(item, recordDir, translations, schema, {
|
|
745
799
|
locale,
|
|
746
800
|
localesDir,
|
|
747
|
-
freeformEnabled: hasFreeform
|
|
801
|
+
freeformEnabled: hasFreeform,
|
|
802
|
+
dataSchema
|
|
748
803
|
})
|
|
749
804
|
)
|
|
750
805
|
)
|
|
@@ -765,7 +820,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
765
820
|
try {
|
|
766
821
|
const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
767
822
|
const translatedRecord = record && typeof record === 'object' && !Array.isArray(record)
|
|
768
|
-
? await translateItemAsync(record, recordDir, translations, schema, { locale, localesDir, freeformEnabled: hasFreeform })
|
|
823
|
+
? await translateItemAsync(record, recordDir, translations, schema, { locale, localesDir, freeformEnabled: hasFreeform, dataSchema })
|
|
769
824
|
: record
|
|
770
825
|
await mkdir(localeRecordsDir, { recursive: true })
|
|
771
826
|
await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
|
|
@@ -803,42 +858,67 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
803
858
|
/**
|
|
804
859
|
* Apply translations to a collection item (async, with free-form support)
|
|
805
860
|
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
861
|
+
* The hash-based translations apply first — every field and every ProseMirror document
|
|
862
|
+
* (schema-guided or heuristic) — and a free-form translation then replaces what it
|
|
863
|
+
* states (`applyFreeform`): its frontmatter, its body, or both.
|
|
864
|
+
*
|
|
865
|
+
* @param {Object} [options.dataSchema] - the data schema the record is delivered in
|
|
866
|
+
* (`recordQueries`), which says where a free-form body and frontmatter go
|
|
809
867
|
*/
|
|
810
868
|
async function translateItemAsync(item, recordDir, translations, schema, options = {}) {
|
|
811
|
-
const { locale, localesDir, freeformEnabled } = options
|
|
812
|
-
const translated =
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
//
|
|
817
|
-
// and
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
869
|
+
const { locale, localesDir, freeformEnabled, dataSchema = null } = options
|
|
870
|
+
const translated = translateItemSync(item, recordDir, translations, schema)
|
|
871
|
+
if (!freeformEnabled || !locale || !localesDir) return translated
|
|
872
|
+
|
|
873
|
+
// ⛔ The loader takes the record's SCHEMA ref and derives the pool path from it
|
|
874
|
+
// (`buildFreeformRecordPath`); handed the pool directory itself (`article`), it
|
|
875
|
+
// derived nothing and found no file — so until 2026-09-14 no free-form record
|
|
876
|
+
// translation applied on this lane. And it is named by the record's HANDLE
|
|
877
|
+
// (`recordHandle`), never a brief's `slug` field.
|
|
878
|
+
const schemaRef = schemaForPoolDirs(recordDir.split('/'))
|
|
879
|
+
const freeform = await loadFreeformRecord({ slug: item.$name ?? item.slug }, schemaRef, locale, localesDir)
|
|
880
|
+
return freeform ? applyFreeform(translated, freeform, dataSchema, schemaRef) : translated
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* A free-form translation over a record, in the shape the record is DELIVERED in (see
|
|
885
|
+
* `site/query-processor.js::deliverRecord`): its frontmatter read the way the record's
|
|
886
|
+
* file is — by section, the brief lifted to the top — and merged section by section; its
|
|
887
|
+
* body put in the schema's content body field — the markdown source for a markup `text`
|
|
888
|
+
* field — or at `content` for a record with no schema or no such field. ⛔ Until
|
|
889
|
+
* 2026-09-24 both went to the top of the record, where a delivered `@std/article` holds
|
|
890
|
+
* neither its body nor anything of `article_body`.
|
|
891
|
+
*
|
|
892
|
+
* ⛔ Frontmatter written in the retired flat form is refused, as the record's own file is.
|
|
893
|
+
*/
|
|
894
|
+
function applyFreeform(record, freeform, dataSchema, schemaRef) {
|
|
895
|
+
const out = { ...record }
|
|
896
|
+
if (freeform.frontmatter) {
|
|
897
|
+
let fm = freeform.frontmatter
|
|
898
|
+
if (dataSchema) {
|
|
899
|
+
const misplaced = misplacedFields(dataSchema, fm)
|
|
900
|
+
if (misplaced.length) {
|
|
901
|
+
throw new Error(flatFormRefusal(freeform.relativePath ?? freeform.filePath, schemaRef, dataSchema, misplaced))
|
|
836
902
|
}
|
|
903
|
+
fm = toDeliveredRecord(dataSchema, fm)
|
|
904
|
+
}
|
|
905
|
+
const sections = new Set(Object.keys(dataSchema?.sections || {}))
|
|
906
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
907
|
+
out[key] = sections.has(key) && isPlainObject(out[key]) && isPlainObject(value) ? { ...out[key], ...value } : value
|
|
837
908
|
}
|
|
838
909
|
}
|
|
910
|
+
if (freeform.content) {
|
|
911
|
+
const target = dataSchema ? contentBodyField(dataSchema) : null
|
|
912
|
+
const value = target?.field?.type === 'text' ? freeform.markdown : freeform.content
|
|
913
|
+
if (!target) out.content = freeform.content
|
|
914
|
+
else if (!target.section) out[target.key] = value
|
|
915
|
+
else out[target.section] = { ...(isPlainObject(out[target.section]) ? out[target.section] : {}), [target.key]: value }
|
|
916
|
+
}
|
|
917
|
+
return out
|
|
918
|
+
}
|
|
839
919
|
|
|
840
|
-
|
|
841
|
-
return
|
|
920
|
+
function isPlainObject(value) {
|
|
921
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
842
922
|
}
|
|
843
923
|
|
|
844
924
|
/**
|
|
@@ -846,8 +926,7 @@ async function translateItemAsync(item, recordDir, translations, schema, options
|
|
|
846
926
|
*/
|
|
847
927
|
function translateItemSync(item, recordDir, translations, schema) {
|
|
848
928
|
const translated = { ...item }
|
|
849
|
-
const
|
|
850
|
-
const context = { record: `${recordDir}/${slug}` }
|
|
929
|
+
const context = { record: `${recordDir}/${recordHandle(item)}` }
|
|
851
930
|
|
|
852
931
|
if (schema?.fields) {
|
|
853
932
|
return translateWithSchema(translated, schema, context, translations)
|
|
@@ -962,7 +1041,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
962
1041
|
const records = one ? [items] : items
|
|
963
1042
|
|
|
964
1043
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
965
|
-
const
|
|
1044
|
+
const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
|
|
1045
|
+
const recordDir = poolDirs.get(queryName) ?? queryName
|
|
966
1046
|
|
|
967
1047
|
const translated = freeformEnabled
|
|
968
1048
|
? await Promise.all(
|
|
@@ -970,7 +1050,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
970
1050
|
translateItemAsync(item, recordDir, translations, schema, {
|
|
971
1051
|
locale,
|
|
972
1052
|
localesDir,
|
|
973
|
-
freeformEnabled
|
|
1053
|
+
freeformEnabled,
|
|
1054
|
+
dataSchema: dataSchemas.get(queryName) ?? null
|
|
974
1055
|
})
|
|
975
1056
|
)
|
|
976
1057
|
)
|
|
@@ -72,6 +72,13 @@ export {
|
|
|
72
72
|
// across foundations — locally, with no backend. '@uniweb' is reserved for the
|
|
73
73
|
// platform system namespace and is never a data-schema source.
|
|
74
74
|
const SCOPE_PACKAGE = { std: '@uniweb/schemas' }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `code` of the error a `@/name` ref raises when the foundation defines no schema of
|
|
78
|
+
* that name — the one failure that is an answer rather than a fault: a query named after
|
|
79
|
+
* no schema is a query over schema-less records.
|
|
80
|
+
*/
|
|
81
|
+
export const SCHEMA_NOT_FOUND = 'SCHEMA_NOT_FOUND'
|
|
75
82
|
const RESERVED_SYSTEM_SCOPE = 'uniweb'
|
|
76
83
|
const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
|
|
77
84
|
|
|
@@ -121,7 +128,10 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
|
|
|
121
128
|
const file = findSelfSchemaFile(srcDir, name)
|
|
122
129
|
if (!file) {
|
|
123
130
|
const tried = SCHEMA_EXTENSIONS.map((e) => `schemas/${name}${e}`).join(', ')
|
|
124
|
-
throw
|
|
131
|
+
throw Object.assign(
|
|
132
|
+
new Error(`Data schema '${ref}' not found. Expected one of: ${tried} under the foundation root.`),
|
|
133
|
+
{ code: SCHEMA_NOT_FOUND }
|
|
134
|
+
)
|
|
125
135
|
}
|
|
126
136
|
return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
|
|
127
137
|
}
|
package/src/runtime-schema.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { isRichSchema } from '@uniweb/core'
|
|
28
|
-
import {
|
|
28
|
+
import { deliveredFields } from '@uniweb/schemas/conform'
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
|
|
@@ -193,22 +193,20 @@ function leanDataSchema(value, dataSchemaMap) {
|
|
|
193
193
|
: (value && typeof value.schema === 'string' ? value.schema : null)
|
|
194
194
|
if (ref) {
|
|
195
195
|
const resolved = dataSchemaMap[ref]
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// reading it directly returned null for every `@std/*` binding and the
|
|
203
|
-
// section's `data:` declaration supplied no field defaults at all. Silent:
|
|
204
|
-
// the data still arrived, just without its defaults.
|
|
196
|
+
// ⭐ THE DELIVERED RECORD'S FIELDS (`deliveredFields`) — the brief's at the top and each
|
|
197
|
+
// other section as one field under its name — so a default lands where the record
|
|
198
|
+
// a component receives carries its field: `@std/article`'s `status` inside
|
|
199
|
+
// `article_body`, as the records service delivers it (measured 2026-09-24). A section
|
|
200
|
+
// is filled only when the record holds it (`applySchemaToObject` recurses into what is
|
|
201
|
+
// there), so a list of briefs gains no section it was not sent.
|
|
205
202
|
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
203
|
+
// ⛔ It was `flatRecordFields` until 2026-09-24 — the retired flat form, every single
|
|
204
|
+
// section's fields at the top — so on a delivered record those defaults landed beside
|
|
205
|
+
// the section they belong to. And before 2026-09-03 it read `resolved.fields`, which a
|
|
206
|
+
// sections-form schema does not have: no defaults at all. `dataSchemaMap` holds each
|
|
207
|
+
// schema normalized in its authored form (`fields:` or `sections:`), and only these
|
|
208
|
+
// readers say what one record of it looks like.
|
|
209
|
+
const fields = deliveredFields(resolved)
|
|
212
210
|
if (!fields) return null
|
|
213
211
|
const lean = extractSchemaFields(fields)
|
|
214
212
|
return Object.keys(lean).length > 0 ? lean : null
|