@uniweb/build 0.60.0 → 0.62.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 +7 -7
- package/src/i18n/freeform.js +3 -1
- package/src/i18n/records.js +161 -80
- package/src/index.js +0 -1
- 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/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 +370 -201
- package/src/uwx/registry-package.js +3 -3
- package/src/uwx/sync-package.js +66 -4
- package/src/uwx/uuid.js +7 -0
- package/src/validate-data.js +97 -180
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
|
|
29
|
-
import { toDataSchemaDeclaration } from './data-schema.js'
|
|
29
|
+
import { toDataSchemaDeclaration, SHORTHAND_SECTION } from './data-schema.js'
|
|
30
30
|
import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
|
|
31
31
|
|
|
32
32
|
const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
|
|
@@ -231,8 +231,8 @@ function makeOptionsResolver(dataSchemas, scoped) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
function itemBearingSectionName(normalized, ref) {
|
|
234
|
-
// fields-form → the
|
|
235
|
-
if (normalized.fields) return
|
|
234
|
+
// fields-form → the shorthand's one section, always named `brief`
|
|
235
|
+
if (normalized.fields) return SHORTHAND_SECTION
|
|
236
236
|
// sections-form → the first item-bearing (single/multi) section
|
|
237
237
|
for (const [name, sec] of Object.entries(normalized.sections || {})) {
|
|
238
238
|
const kind = sec.kind || 'single'
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -33,6 +33,18 @@ const SITE_ENTITY_KEY = 'site-content'
|
|
|
33
33
|
|
|
34
34
|
const cacheKey = (entity) => `${entity.model} ${entity.id}`
|
|
35
35
|
|
|
36
|
+
// The folder's placements without the records a push leaves out — `{ kind: 'ref' }` leaves
|
|
37
|
+
// naming them by `$entityId`, inside any branch.
|
|
38
|
+
function withoutRecords(nodes, ids) {
|
|
39
|
+
if (!ids.size) return nodes
|
|
40
|
+
const out = []
|
|
41
|
+
for (const node of nodes || []) {
|
|
42
|
+
if (node?.kind === 'branch') out.push({ ...node, $children: withoutRecords(node.$children, ids) })
|
|
43
|
+
else if (!ids.has(node?.$entityId)) out.push(node)
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
// Attach the push gate's optimistic-concurrency token to an entity, keyed by the
|
|
37
49
|
// BACKEND uuid in its document body (`$uuid`). A never-synced entity has no
|
|
38
50
|
// `$uuid` and therefore no base — correctly unconditional, since there is no
|
|
@@ -253,7 +265,11 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
253
265
|
* records: { buffer, entityCount, index, models }|null,
|
|
254
266
|
* hashes: Object<string,string>, warnings: string[], refusals: string[],
|
|
255
267
|
* skipped: number, schemaless: Array<{name: string, model: string}>,
|
|
256
|
-
* localAssets: string[], applied: object
|
|
268
|
+
* localAssets: string[], applied: object,
|
|
269
|
+
* waiting: Array<{id: string, model: string, slug: string, held: boolean, pending: object[]}> }>}
|
|
270
|
+
* `waiting` lists the records whose references name a record this backend has not
|
|
271
|
+
* minted yet — sent without them, or held back. A caller that sends pushes again
|
|
272
|
+
* once this push has been applied (`records.js::encodeReference`).
|
|
257
273
|
* `refusals` names the records that cannot be sent as written, one line each
|
|
258
274
|
* (`records.js::recordsToEntities`). A caller that submits must not submit while
|
|
259
275
|
* any is present: the backend would refuse the records lane — or, for a record
|
|
@@ -300,10 +316,43 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
300
316
|
})
|
|
301
317
|
const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
|
|
302
318
|
|
|
319
|
+
// ⭐ RECORDS WAITING FOR A REFERENCE (`records.js::encodeReference`). A record whose
|
|
320
|
+
// reference names a record this backend has not minted yet cannot name it on the wire,
|
|
321
|
+
// so this push sends what it can and the next pass completes it (the CLI's
|
|
322
|
+
// `pushInPasses`):
|
|
323
|
+
// · new, and nothing it waits for is required → sent now without those references,
|
|
324
|
+
// so after one pass every record exists, whatever refers to what;
|
|
325
|
+
// · new, and a required reference waits → HELD and placed nowhere: it cannot be
|
|
326
|
+
// created without it;
|
|
327
|
+
// · already on this backend → HELD, left as it is there: sent without a reference it
|
|
328
|
+
// has, it would lose that reference until the next pass.
|
|
329
|
+
// A held record is not sent and keeps the hash last banked for it, so the next pass
|
|
330
|
+
// sees it as changed.
|
|
331
|
+
const held = new Set()
|
|
332
|
+
const unplaced = new Set()
|
|
333
|
+
for (const e of col.entities) {
|
|
334
|
+
if (!e.pending?.length) continue
|
|
335
|
+
if (e.uuid) held.add(e.id)
|
|
336
|
+
else if (e.pending.some((p) => p.required)) {
|
|
337
|
+
held.add(e.id)
|
|
338
|
+
unplaced.add(e.id)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const waiting = col.entities
|
|
342
|
+
.filter((e) => e.pending?.length)
|
|
343
|
+
.map((e) => ({ id: e.id, model: e.model, slug: e.slug, held: held.has(e.id), pending: e.pending }))
|
|
344
|
+
const placedEntities = []
|
|
345
|
+
const placedIndex = []
|
|
346
|
+
col.entities.forEach((e, i) => {
|
|
347
|
+
if (unplaced.has(e.id)) return
|
|
348
|
+
placedEntities.push(e)
|
|
349
|
+
placedIndex.push(col.index[i])
|
|
350
|
+
})
|
|
351
|
+
|
|
303
352
|
// The folder rides over the FULL record set (before filtering) so its references
|
|
304
353
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
305
354
|
const folder = buildFolderEntity({
|
|
306
|
-
recordEntities:
|
|
355
|
+
recordEntities: placedEntities,
|
|
307
356
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
308
357
|
// ⭐ Every record in the directory, at the top of the folder or in the
|
|
309
358
|
// sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
|
|
@@ -311,7 +360,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
311
360
|
// non-empty tree: a `folder.yml` whose folders hold only records that resolved
|
|
312
361
|
// no schema is a tree of empty branches, and sending it would replace the
|
|
313
362
|
// backend's folder with them (measured on the `dynamic` template, 2026-09-21).
|
|
314
|
-
folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
|
|
363
|
+
folderNodes: col.sendFolder === true ? withoutRecords(col.folder?.nodes ?? [], unplaced) : [],
|
|
315
364
|
// ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
|
|
316
365
|
// not whether it holds anything. A site with no records directory sends none
|
|
317
366
|
// and leaves the backend's alone; an empty directory sends an empty folder,
|
|
@@ -469,7 +518,17 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
469
518
|
// changed() has side effects (hashes/skipped), so evaluate every entity exactly
|
|
470
519
|
// once, in a stable order: folder, then each record.
|
|
471
520
|
const folderChanged = folder ? changed(folder) : false
|
|
472
|
-
|
|
521
|
+
// A held record keeps its last banked hash — it was not sent (see `held` above).
|
|
522
|
+
const keepBanked = (entity) => {
|
|
523
|
+
const key = cacheKey(entity)
|
|
524
|
+
if (priorHashes[key] !== undefined) hashes[key] = priorHashes[key]
|
|
525
|
+
return false
|
|
526
|
+
}
|
|
527
|
+
const recordChanged = placedEntities.map((e, i) => ({
|
|
528
|
+
entity: e,
|
|
529
|
+
index: placedIndex[i],
|
|
530
|
+
changed: held.has(e.id) ? keepBanked(e) : changed(e),
|
|
531
|
+
}))
|
|
473
532
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
474
533
|
|
|
475
534
|
let records = null
|
|
@@ -523,6 +582,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
523
582
|
|
|
524
583
|
return {
|
|
525
584
|
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
585
|
+
// Records waiting for a reference's record — see `held` above. Non-empty ⇒ the caller
|
|
586
|
+
// pushes again once this push has been applied.
|
|
587
|
+
waiting,
|
|
526
588
|
refusals: col.refusals || [],
|
|
527
589
|
schemaless: col.schemaless, localAssets, applied,
|
|
528
590
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
package/src/uwx/uuid.js
CHANGED
|
@@ -42,3 +42,10 @@ export function mintUuidV7() {
|
|
|
42
42
|
hex.slice(20)
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
47
|
+
|
|
48
|
+
/** Is this a uuid (any version), as the exchange writes one? */
|
|
49
|
+
export function isUuid(value) {
|
|
50
|
+
return typeof value === 'string' && UUID_SHAPE.test(value)
|
|
51
|
+
}
|
package/src/validate-data.js
CHANGED
|
@@ -31,7 +31,8 @@ import { YAML_OPTIONS } from './utils/yaml-schema.js'
|
|
|
31
31
|
import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
|
|
32
32
|
import { parentRouteOf } from '@uniweb/core/route-match'
|
|
33
33
|
|
|
34
|
-
import { validateItem,
|
|
34
|
+
import { validateItem, validateRecordFile, validateBound, contentBodyField, rootListSection, referencesOf } from '@uniweb/schemas/conform'
|
|
35
|
+
import { isUuid } from './uwx/uuid.js'
|
|
35
36
|
import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
36
37
|
|
|
37
38
|
// The pure checker, re-exported so `@uniweb/build/validate` stays the one import
|
|
@@ -41,7 +42,6 @@ export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
|
41
42
|
import { buildSchema } from './schema.js'
|
|
42
43
|
import { resolveRecordsDir, readEntityPool, groupPoolBySchema } from './site/entity-pool.js'
|
|
43
44
|
import { readEntityFile } from './uwx/entity-source.js'
|
|
44
|
-
import { isContentBodyField } from './uwx/data-schema.js'
|
|
45
45
|
import { toFetchList } from './site/data-fetcher.js'
|
|
46
46
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
47
47
|
import { collectSiteContent } from './site/content-collector.js'
|
|
@@ -75,7 +75,7 @@ import { processQueries } from './site/query-processor.js'
|
|
|
75
75
|
*
|
|
76
76
|
* @typedef {Object} Report
|
|
77
77
|
* @property {Array<Object>} violations - Each: { file, schema, item, field, rule, message, users }.
|
|
78
|
-
* @property {Array<Object>} deferred - Inputs not
|
|
78
|
+
* @property {Array<Object>} deferred - Inputs not checked: data not in the project (an external query), or an inline schema on a binding — { route, section, key, reason, ref?, url? }.
|
|
79
79
|
* @property {Array<Object>} setupErrors - Read failures: { file, message, users }.
|
|
80
80
|
* @property {{ records: number, schemas: number, violations: number, deferred: number }} summary
|
|
81
81
|
*/
|
|
@@ -197,13 +197,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
197
197
|
const schema = dataSchemas[ref]
|
|
198
198
|
if (!schema) continue // build guarantees refs resolve; defensive skip
|
|
199
199
|
|
|
200
|
-
// ⭐
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
deferred.push({ route: page.route, section: type, key, reason: 'its root is a list, not a record', ref })
|
|
205
|
-
continue
|
|
206
|
-
}
|
|
200
|
+
// ⭐ Every schema is checked (2026-09-24): a sections-form one per record, flat or
|
|
201
|
+
// written by section, and one whose ROOT IS A LIST as the list the key receives
|
|
202
|
+
// (pass 2). A list root was deferred here until then, though the query's records
|
|
203
|
+
// are exactly that list.
|
|
207
204
|
|
|
208
205
|
const pairKey = `${input.path} ${ref}`
|
|
209
206
|
let entry = work.get(pairKey)
|
|
@@ -220,7 +217,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
220
217
|
const violations = []
|
|
221
218
|
const schemasSeen = new Set()
|
|
222
219
|
let recordCount = 0
|
|
223
|
-
// (schema, slug) of every record this pass checked, so pass
|
|
220
|
+
// (schema, slug) of every record this pass checked, so pass 4 checks the rest.
|
|
224
221
|
const checkedRecords = new Set()
|
|
225
222
|
|
|
226
223
|
for (const entry of work.values()) {
|
|
@@ -232,6 +229,19 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
232
229
|
|
|
233
230
|
schemasSeen.add(entry.ref)
|
|
234
231
|
const items = Array.isArray(records) ? records : [records]
|
|
232
|
+
|
|
233
|
+
// A schema whose ROOT IS A LIST describes the whole value the key receives, and the
|
|
234
|
+
// query's records ARE that list — so they are checked once, as the list. Any other
|
|
235
|
+
// schema describes one record, checked per record.
|
|
236
|
+
if (rootListSection(entry.schema)) {
|
|
237
|
+
recordCount += items.length
|
|
238
|
+
const base = { file: entry.path, schema: entry.ref, users: entry.users }
|
|
239
|
+
for (const finding of validateBound(entry.schema, items)) {
|
|
240
|
+
violations.push(listViolation(finding, (idx) => itemLabel(items[idx], idx), base))
|
|
241
|
+
}
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
235
245
|
items.forEach((item, idx) => {
|
|
236
246
|
recordCount++
|
|
237
247
|
if (item && typeof item.slug === 'string') checkedRecords.add(recordKey(entry.ref, item.slug))
|
|
@@ -247,21 +257,21 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
247
257
|
})
|
|
248
258
|
}
|
|
249
259
|
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
// ⛔ No pass checks a ```md:<tag> CONCEPT BLOCK — removed 2026-09-24 [Diego]. Its
|
|
261
|
+
// structure is recovered from the markdown by the semantic parser, so a schema has
|
|
262
|
+
// nothing to add: the pass matched `md:<tag>` to `@std/<tag>` and checked the parsed
|
|
263
|
+
// items, which either could not produce a finding (a schema in the item vocabulary)
|
|
264
|
+
// or produced ones no author could satisfy (`name` on `md:person` — markdown has no
|
|
265
|
+
// `name`). It was advisory while findings only warned; they now fail the run.
|
|
256
266
|
|
|
257
|
-
// Pass
|
|
267
|
+
// Pass 3 — tagged data blocks, which join by the component's OWN binding.
|
|
258
268
|
const blocks = validateTaggedDataBlocks(site, foundation, dataSchemas)
|
|
259
269
|
violations.push(...blocks.violations)
|
|
260
270
|
deferred.push(...blocks.deferred)
|
|
261
271
|
for (const ref of blocks.schemas) schemasSeen.add(ref)
|
|
262
272
|
recordCount += blocks.checked
|
|
263
273
|
|
|
264
|
-
// Pass
|
|
274
|
+
// Pass 4 — every record file no section's binding reached, against the data schema
|
|
265
275
|
// its folder names. ⭐ The set a push sends: every file in the records directory is
|
|
266
276
|
// a record and every record is pushed, whether or not a section reads it — and
|
|
267
277
|
// until 2026-09-24 one that no section read was never checked here.
|
|
@@ -286,13 +296,30 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
286
296
|
|
|
287
297
|
const recordKey = (ref, slug) => `${ref}\u0000${slug}`
|
|
288
298
|
|
|
299
|
+
// A finding from checking a whole list (`validateBound`) names its element by index —
|
|
300
|
+
// `[3].label` — so the element is named the way a per-record finding names its record,
|
|
301
|
+
// by `label(3)`, and the path is left relative to it.
|
|
302
|
+
function listViolation(finding, label, base) {
|
|
303
|
+
const m = /^\[(\d+)\]\.?/.exec(finding.field || '')
|
|
304
|
+
if (!m) return { ...base, item: 'the list', ...finding }
|
|
305
|
+
const idx = Number(m[1])
|
|
306
|
+
return { ...base, item: label(idx), ...finding, field: finding.field.slice(m[0].length) || finding.field }
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
/**
|
|
290
310
|
* Check the records in the records directory that pass 2 did not, each against the
|
|
291
311
|
* data schema its folder names (`records/member/` → `@/member`).
|
|
292
312
|
*
|
|
293
|
-
* A record is
|
|
294
|
-
*
|
|
295
|
-
*
|
|
313
|
+
* A record is checked as its FILE holds it (`validateRecordFile`) — read the way a push
|
|
314
|
+
* reads it (`uwx/entity-source.js`), a markdown body in its schema's content body field
|
|
315
|
+
* unless the file sets that field, which is what a push sends it as (`uwx/records.js`).
|
|
316
|
+
* So a finding names the path an author edits (`article.title`), and a field written in
|
|
317
|
+
* the retired flat form is reported under the rule `section`. Pass 2 checks the records
|
|
318
|
+
* a section receives, in the shape it receives them (`validateItem`).
|
|
319
|
+
*
|
|
320
|
+
* Every record's references are checked here too, whichever pass checked the rest: one
|
|
321
|
+
* that names no record of its schema is reported under the rule `ref`, since a push
|
|
322
|
+
* cannot send it.
|
|
296
323
|
*
|
|
297
324
|
* A folder whose schema resolves to nothing is left out in silence: its records are
|
|
298
325
|
* schema-less, delivered as files, and the push says so itself.
|
|
@@ -302,9 +329,14 @@ const recordKey = (ref, slug) => `${ref}\u0000${slug}`
|
|
|
302
329
|
async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, checked }) {
|
|
303
330
|
const out = { violations: [], setupErrors: [], schemas: new Set(), checked: 0 }
|
|
304
331
|
const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
|
|
332
|
+
// Every record is read first: a reference names a record of another schema by its
|
|
333
|
+
// name — its file's, or its `slug:` — or by the id its file carries.
|
|
334
|
+
const read = []
|
|
335
|
+
const names = new Map() // schema (as its folder names it) → the names and ids of its records
|
|
305
336
|
for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
|
|
306
337
|
const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
|
|
307
|
-
|
|
338
|
+
const known = names.get(ref) ?? new Set()
|
|
339
|
+
names.set(ref, known)
|
|
308
340
|
for (const pooled of entities) {
|
|
309
341
|
let records
|
|
310
342
|
try {
|
|
@@ -314,13 +346,39 @@ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, check
|
|
|
314
346
|
continue
|
|
315
347
|
}
|
|
316
348
|
for (const r of records) {
|
|
317
|
-
if (
|
|
349
|
+
if (r.slug) known.add(String(r.slug))
|
|
350
|
+
if (typeof r.data?.$uuid === 'string') known.add(r.data.$uuid)
|
|
351
|
+
}
|
|
352
|
+
// An entity of a list-rooted schema too: it holds the list under its section's key.
|
|
353
|
+
// ⛔ Until 2026-09-24 such a folder was skipped here without a word.
|
|
354
|
+
if (schema) read.push({ ref, schema, pooled, records })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const { ref, schema, pooled, records } of read) {
|
|
358
|
+
for (const r of records) {
|
|
359
|
+
if (!r.slug) continue
|
|
360
|
+
const record = withBody(schema, r)
|
|
361
|
+
if (!checked.has(recordKey(ref, r.slug))) {
|
|
318
362
|
out.checked++
|
|
319
363
|
out.schemas.add(ref)
|
|
320
|
-
for (const finding of
|
|
364
|
+
for (const finding of validateRecordFile(schema, record)) {
|
|
321
365
|
out.violations.push({ file: pooled.relPath, schema: ref, item: r.slug, users: [], ...finding })
|
|
322
366
|
}
|
|
323
367
|
}
|
|
368
|
+
// ⭐ A reference must name a record — a push refuses one that does not. A uuid is
|
|
369
|
+
// left alone: it may be a record the backend holds and this project does not.
|
|
370
|
+
for (const { path, ref: target, value } of referencesOf(schema, record)) {
|
|
371
|
+
if (typeof value !== 'string' || isUuid(value) || names.get(target)?.has(value)) continue
|
|
372
|
+
out.violations.push({
|
|
373
|
+
file: pooled.relPath,
|
|
374
|
+
schema: ref,
|
|
375
|
+
item: r.slug,
|
|
376
|
+
users: [],
|
|
377
|
+
field: path,
|
|
378
|
+
rule: 'ref',
|
|
379
|
+
message: `names "${value}", and no record of ${target} is called that`,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
324
382
|
}
|
|
325
383
|
}
|
|
326
384
|
return out
|
|
@@ -337,138 +395,21 @@ async function schemaForRecords(ref, { srcDir, dataSchemas }) {
|
|
|
337
395
|
}
|
|
338
396
|
}
|
|
339
397
|
|
|
340
|
-
// A record as
|
|
341
|
-
// when the
|
|
398
|
+
// A record as its file holds it, with a markdown body in the schema's content body field
|
|
399
|
+
// — under its section, in a file written by section — when the file does not set it.
|
|
342
400
|
function withBody(schema, r) {
|
|
343
401
|
const record = { ...(r.data || {}) }
|
|
344
402
|
if (typeof r.body !== 'string' || r.body.trim() === '') return record
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Check each ```md:<tag> concept block against `@std/<tag>`, when that schema
|
|
352
|
-
* exists.
|
|
353
|
-
*
|
|
354
|
-
* THREE PROPERTIES MAKE THIS SAFE, and all three have to hold:
|
|
355
|
-
*
|
|
356
|
-
* 1. It adds NO REGISTRY. The resolution is mechanical — `md:faq` → `@std/faq`,
|
|
357
|
-
* the same `@std` → `@uniweb/schemas` mapping every other ref uses. What the
|
|
358
|
-
* framework gains is a naming convention; no code branches on the value of a
|
|
359
|
-
* tag, and nothing here knows which concepts exist. A hardcoded list of
|
|
360
|
-
* concept names is the thing this whole design exists to avoid, and it would
|
|
361
|
-
* arrive through this door if the check needed to know what `faq` means.
|
|
362
|
-
*
|
|
363
|
-
* 2. It never touches SHAPE. A concept block's shape comes from its fence,
|
|
364
|
-
* unconditionally. This runs after the parse and changes nothing: a block
|
|
365
|
-
* with no resolvable schema still parses, still delivers items, still
|
|
366
|
-
* renders. The schema is a check, never a gate.
|
|
367
|
-
*
|
|
368
|
-
* 3. It never fails at RENDER. Findings only — this whole module is a pre-live
|
|
369
|
-
* dev/CI gate and the runtime stays tolerant.
|
|
370
|
-
*
|
|
371
|
-
* ⛔ A standard schema for a concept MUST be authored in the ITEM vocabulary —
|
|
372
|
-
* `title`, `paragraphs`, and the rest of the parsed shape — because that is what
|
|
373
|
-
* a concept block always produces. An `@std/faq` written as `{ question, answer }`
|
|
374
|
-
* could only be checked with a per-concept field mapping, which is the forbidden
|
|
375
|
-
* registry arriving by the back door. Author the schema to match the parse, or
|
|
376
|
-
* do not ship the schema.
|
|
377
|
-
*
|
|
378
|
-
* ⛔ AND FOR A PROSE CONCEPT, NO FACET CAN FIRE AT ALL — so do not write an
|
|
379
|
-
* `@std` schema for one. Measured 2026-07-30:
|
|
380
|
-
*
|
|
381
|
-
* - `required` is inert. The item vocabulary is TOTAL — `flattenGroup` fills
|
|
382
|
-
* every field it declares, so a titleless item has `title: ''` rather than
|
|
383
|
-
* no title, and `required` fires only on absent or null. "The author
|
|
384
|
-
* actually wrote a question" is not expressible.
|
|
385
|
-
* - `type` cannot fail either. Inside a concept block `title` is always a
|
|
386
|
-
* string (never an array — `alwaysItems` suppresses the same-level merge
|
|
387
|
-
* that would make one) and `paragraphs` is always an array of strings.
|
|
388
|
-
* - which leaves `enum` / `format`, and neither has a natural application to
|
|
389
|
-
* a question or an answer. The test suite had to invent `format: 'url'` on
|
|
390
|
-
* a question to make anything fire — that is the tell, not a fixture quirk.
|
|
391
|
-
*
|
|
392
|
-
* The mechanism still earns its place, but it is waiting for a different shape:
|
|
393
|
-
* a concept that carries a tagged DATA BLOCK. Verified that one reaches the item
|
|
394
|
-
* — ```` ```md:steps ```` holding a ```` ```yaml:meta ```` gives
|
|
395
|
-
* `items[0].data.meta` — and there `required` fires when an author omits the
|
|
396
|
-
* block, `enum` constrains a status, `format` constrains a duration. That is the
|
|
397
|
-
* trigger to write a schema. Until then the frontend holds the concept names and
|
|
398
|
-
* their shapes, which is where they belong: its extension encodes the shape
|
|
399
|
-
* executably, and a `standard/faq.js` in `@uniweb/schemas` whose only consumer is
|
|
400
|
-
* that app would be this framework stating which concepts exist — the registry
|
|
401
|
-
* this design forbids, spelled as a filename instead of a switch.
|
|
402
|
-
*
|
|
403
|
-
* Note on resolution: this deliberately does NOT go through `resolveSchemaRef`,
|
|
404
|
-
* which resolves a package from a FOUNDATION's node_modules and throws when a
|
|
405
|
-
* ref is unknown. Neither fits — a concept block needs no foundation (so this
|
|
406
|
-
* works on a link-mode site whose foundation is a registry ref with nothing
|
|
407
|
-
* local), and an unresolved tag must be silent rather than an error. So the
|
|
408
|
-
* package is resolved from this build's own graph, where it is an
|
|
409
|
-
* optionalDependency, exactly as `i18n/records.js` resolves it.
|
|
410
|
-
*
|
|
411
|
-
* @param {Object} site - collected site content (`{ pages }`)
|
|
412
|
-
* @returns {Promise<{ violations: Array, schemas: Set<string>, checked: number }>}
|
|
413
|
-
*/
|
|
414
|
-
export async function validateConceptBlocks(site) {
|
|
415
|
-
const empty = { violations: [], schemas: new Set(), checked: 0 }
|
|
416
|
-
|
|
417
|
-
const parse = await loadSemanticParser()
|
|
418
|
-
if (!parse) return empty // no parser available — nothing to derive items from
|
|
419
|
-
|
|
420
|
-
const standards = await loadStandardSchemas()
|
|
421
|
-
if (!standards) return empty // @uniweb/schemas absent — nothing to check against
|
|
422
|
-
|
|
423
|
-
const violations = []
|
|
424
|
-
const schemasSeen = new Set()
|
|
425
|
-
let checked = 0
|
|
426
|
-
|
|
427
|
-
for (const page of site.pages || []) {
|
|
428
|
-
walkSections(page.sections || [], (section) => {
|
|
429
|
-
const doc = section.content
|
|
430
|
-
if (doc?.type !== 'doc') return
|
|
431
|
-
|
|
432
|
-
for (const node of conceptBlockNodes(doc)) {
|
|
433
|
-
const tag = node.attrs?.tag
|
|
434
|
-
if (!tag) continue
|
|
435
|
-
|
|
436
|
-
const raw = standards(tag)
|
|
437
|
-
if (!raw) continue // no `@std/<tag>` — say nothing, by design
|
|
438
|
-
|
|
439
|
-
let schema
|
|
440
|
-
try {
|
|
441
|
-
schema = validateAndNormalizeSchema(raw, `@std/${tag}`)
|
|
442
|
-
} catch {
|
|
443
|
-
continue // a malformed standard schema is that package's problem
|
|
444
|
-
}
|
|
445
|
-
if (!isStaticallyCheckable(schema)) continue
|
|
446
|
-
|
|
447
|
-
schemasSeen.add(`@std/${tag}`)
|
|
448
|
-
const { items } = parse({ type: 'doc', content: node.content || [] }, { alwaysItems: true })
|
|
449
|
-
|
|
450
|
-
items.forEach((item, idx) => {
|
|
451
|
-
checked++
|
|
452
|
-
for (const finding of validateItem(schema, item)) {
|
|
453
|
-
violations.push({
|
|
454
|
-
file: `${page.route || '/'} › ${section.type || 'section'} › md:${tag}`,
|
|
455
|
-
schema: `@std/${tag}`,
|
|
456
|
-
item: `item ${idx + 1}`,
|
|
457
|
-
users: [{ route: page.route, section: section.type, key: tag }],
|
|
458
|
-
...finding,
|
|
459
|
-
})
|
|
460
|
-
}
|
|
461
|
-
})
|
|
462
|
-
}
|
|
463
|
-
})
|
|
403
|
+
const target = contentBodyField(schema)
|
|
404
|
+
if (!target) return record
|
|
405
|
+
if (!target.fileSection) {
|
|
406
|
+
if (record[target.key] === undefined) record[target.key] = r.body
|
|
407
|
+
return record
|
|
464
408
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** Every concept block in a doc, including any nested inside a container. */
|
|
470
|
-
function conceptBlockNodes(doc) {
|
|
471
|
-
return nodesOfType(doc, 'concept_block')
|
|
409
|
+
const held = record[target.fileSection]
|
|
410
|
+
if (held != null && (typeof held !== 'object' || Array.isArray(held))) return record
|
|
411
|
+
if (held?.[target.key] === undefined) record[target.fileSection] = { ...(held || {}), [target.key]: r.body }
|
|
412
|
+
return record
|
|
472
413
|
}
|
|
473
414
|
|
|
474
415
|
function nodesOfType(doc, type) {
|
|
@@ -495,11 +436,9 @@ function nodesOfType(doc, type) {
|
|
|
495
436
|
* tagged block fills was never applied to anything. `@std/form` existed for
|
|
496
437
|
* exactly this and had never run outside its own contract test.
|
|
497
438
|
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* actually declared, so there is no naming rule and no registry — a tag nobody
|
|
502
|
-
* bound is simply not governed, and says nothing.
|
|
439
|
+
* The join is the binding the component actually declared, so there is no naming
|
|
440
|
+
* rule and no registry — a tag nobody bound is simply not governed, and says
|
|
441
|
+
* nothing.
|
|
503
442
|
*
|
|
504
443
|
* The value needs no parsing: a tagged fence lands as a `dataBlock` node with its
|
|
505
444
|
* parsed value already on `attrs.data`, and a body that FAILED to parse never
|
|
@@ -562,28 +501,6 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
|
|
|
562
501
|
return { violations, schemas, checked, deferred }
|
|
563
502
|
}
|
|
564
503
|
|
|
565
|
-
/** `parseContent`, or null when the parser is not installed. */
|
|
566
|
-
async function loadSemanticParser() {
|
|
567
|
-
try {
|
|
568
|
-
const mod = await import('@uniweb/semantic-parser')
|
|
569
|
-
return typeof mod.parseContent === 'function' ? mod.parseContent : null
|
|
570
|
-
} catch {
|
|
571
|
-
return null
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/** A `(name) => schema | undefined` lookup over `@std`, or null when absent. */
|
|
576
|
-
async function loadStandardSchemas() {
|
|
577
|
-
try {
|
|
578
|
-
const mod = await import('@uniweb/schemas')
|
|
579
|
-
if (typeof mod.getSchema === 'function') return (name) => mod.getSchema(name)
|
|
580
|
-
const table = mod.schemas ?? mod.default
|
|
581
|
-
return table ? (name) => table[name] : null
|
|
582
|
-
} catch {
|
|
583
|
-
return null
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
504
|
/**
|
|
588
505
|
* The data inputs reaching a section, deduped by key — the fetches of its levels, most
|
|
589
506
|
* specific first; when two levels share a key, the nearer one wins. What each input fills
|