@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.
@@ -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 synthesized single section (named by the ref's short segment)
235
- if (normalized.fields) return String(ref).split('/').pop()
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'
@@ -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: col.entities,
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
- const recordChanged = col.entities.map((e, i) => ({ entity: e, index: col.index[i], changed: changed(e) }))
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
+ }
@@ -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, isStaticallyCheckable, validateBound, flatRecordFields } from '@uniweb/schemas/conform'
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 statically checkable: { route, section, key, reason, ref?, url? }.
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
- // ⭐ A sections-form schema is checked like any other now (`isStaticallyCheckable`,
201
- // 2026-09-24) — a record can be flat or written by section. What is left out is a
202
- // schema whose ROOT IS A LIST: it describes a whole value, not one record.
203
- if (!isStaticallyCheckable(schema)) {
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 5 checks the rest.
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
- // Pass 3 — concept blocks, which join to a schema by CONVENTION rather than
251
- // by a foundation binding. Additive and silent unless a schema resolves.
252
- const concepts = await validateConceptBlocks(site)
253
- violations.push(...concepts.violations)
254
- for (const ref of concepts.schemas) schemasSeen.add(ref)
255
- recordCount += concepts.checked
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 4 — tagged data blocks, which join by the component's OWN binding.
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 5 — every record file no section's binding reached, against the data schema
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 read the way a push reads it (`uwx/entity-source.js`), and a markdown
294
- * record's body is the value of its schema's content body field unless its
295
- * frontmatter sets one — which is what a push sends it as (`uwx/records.js`).
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
- if (!schema || !isStaticallyCheckable(schema)) continue
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 (!r.slug || checked.has(recordKey(ref, r.slug))) continue
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 validateItem(schema, withBody(schema, r))) {
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 a push sends it: a markdown body fills the schema's content body field
341
- // when the frontmatter does not set it.
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 key = Object.entries(flatRecordFields(schema) || {}).find(([, f]) => isContentBodyField(f))?.[0]
346
- if (key && record[key] === undefined) record[key] = r.body
347
- return record
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
- return { violations, schemas: schemasSeen, checked }
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
- * Unlike concept blocks (pass 3), the join here is NOT by convention. A concept
499
- * block resolves `md:faq` → `@std/faq` mechanically, which is why that pass must
500
- * stay silent when no such schema exists. This one uses the binding the component
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