@uniweb/build 0.59.1 → 0.61.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.
@@ -31,15 +31,17 @@ 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 } from '@uniweb/schemas/conform'
35
- import { validateAndNormalizeSchema } from './resolve-data-schema.js'
34
+ import { validateItem, validateBound, flatRecordFields, rootListSection } from '@uniweb/schemas/conform'
35
+ import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
36
36
 
37
37
  // The pure checker, re-exported so `@uniweb/build/validate` stays the one import
38
38
  // path callers know (`uniweb validate`, the CLI, and the contract tests all
39
39
  // reach it here) even though the implementation moved next to the vocabulary.
40
40
  export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
41
41
  import { buildSchema } from './schema.js'
42
- import { resolveRecordsDir } from './site/entity-pool.js'
42
+ import { resolveRecordsDir, readEntityPool, groupPoolBySchema } from './site/entity-pool.js'
43
+ import { readEntityFile } from './uwx/entity-source.js'
44
+ import { isContentBodyField } from './uwx/data-schema.js'
43
45
  import { toFetchList } from './site/data-fetcher.js'
44
46
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
45
47
  import { collectSiteContent } from './site/content-collector.js'
@@ -73,7 +75,7 @@ import { processQueries } from './site/query-processor.js'
73
75
  *
74
76
  * @typedef {Object} Report
75
77
  * @property {Array<Object>} violations - Each: { file, schema, item, field, rule, message, users }.
76
- * @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? }.
77
79
  * @property {Array<Object>} setupErrors - Read failures: { file, message, users }.
78
80
  * @property {{ records: number, schemas: number, violations: number, deferred: number }} summary
79
81
  */
@@ -195,10 +197,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
195
197
  const schema = dataSchemas[ref]
196
198
  if (!schema) continue // build guarantees refs resolve; defensive skip
197
199
 
198
- if (!isStaticallyCheckable(schema)) {
199
- deferred.push({ route: page.route, section: type, key, reason: 'rich sections-form schema', ref })
200
- continue
201
- }
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.
202
204
 
203
205
  const pairKey = `${input.path} ${ref}`
204
206
  let entry = work.get(pairKey)
@@ -215,6 +217,8 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
215
217
  const violations = []
216
218
  const schemasSeen = new Set()
217
219
  let recordCount = 0
220
+ // (schema, slug) of every record this pass checked, so pass 4 checks the rest.
221
+ const checkedRecords = new Set()
218
222
 
219
223
  for (const entry of work.values()) {
220
224
  const { records, error } = await resolveRecords(entry.path, { byQuery, siteRoot })
@@ -225,8 +229,22 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
225
229
 
226
230
  schemasSeen.add(entry.ref)
227
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
+
228
245
  items.forEach((item, idx) => {
229
246
  recordCount++
247
+ if (item && typeof item.slug === 'string') checkedRecords.add(recordKey(entry.ref, item.slug))
230
248
  for (const finding of validateItem(entry.schema, item)) {
231
249
  violations.push({
232
250
  file: entry.path,
@@ -239,20 +257,30 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
239
257
  })
240
258
  }
241
259
 
242
- // Pass 3 — concept blocks, which join to a schema by CONVENTION rather than
243
- // by a foundation binding. Additive and silent unless a schema resolves.
244
- const concepts = await validateConceptBlocks(site)
245
- violations.push(...concepts.violations)
246
- for (const ref of concepts.schemas) schemasSeen.add(ref)
247
- 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.
248
266
 
249
- // 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.
250
268
  const blocks = validateTaggedDataBlocks(site, foundation, dataSchemas)
251
269
  violations.push(...blocks.violations)
252
270
  deferred.push(...blocks.deferred)
253
271
  for (const ref of blocks.schemas) schemasSeen.add(ref)
254
272
  recordCount += blocks.checked
255
273
 
274
+ // Pass 4 — every record file no section's binding reached, against the data schema
275
+ // its folder names. ⭐ The set a push sends: every file in the records directory is
276
+ // a record and every record is pushed, whether or not a section reads it — and
277
+ // until 2026-09-24 one that no section read was never checked here.
278
+ const files = await validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths: config.paths, checked: checkedRecords })
279
+ violations.push(...files.violations)
280
+ setupErrors.push(...files.setupErrors)
281
+ for (const ref of files.schemas) schemasSeen.add(ref)
282
+ recordCount += files.checked
283
+
256
284
  return {
257
285
  violations,
258
286
  deferred,
@@ -266,128 +294,79 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
266
294
  }
267
295
  }
268
296
 
297
+ const recordKey = (ref, slug) => `${ref}\u0000${slug}`
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
+
269
309
  /**
270
- * Check each ```md:<tag> concept block against `@std/<tag>`, when that schema
271
- * exists.
272
- *
273
- * THREE PROPERTIES MAKE THIS SAFE, and all three have to hold:
274
- *
275
- * 1. It adds NO REGISTRY. The resolution is mechanical — `md:faq` → `@std/faq`,
276
- * the same `@std` → `@uniweb/schemas` mapping every other ref uses. What the
277
- * framework gains is a naming convention; no code branches on the value of a
278
- * tag, and nothing here knows which concepts exist. A hardcoded list of
279
- * concept names is the thing this whole design exists to avoid, and it would
280
- * arrive through this door if the check needed to know what `faq` means.
281
- *
282
- * 2. It never touches SHAPE. A concept block's shape comes from its fence,
283
- * unconditionally. This runs after the parse and changes nothing: a block
284
- * with no resolvable schema still parses, still delivers items, still
285
- * renders. The schema is a check, never a gate.
310
+ * Check the records in the records directory that pass 2 did not, each against the
311
+ * data schema its folder names (`records/member/` → `@/member`).
286
312
  *
287
- * 3. It never fails at RENDER. Findings only — this whole module is a pre-live
288
- * dev/CI gate and the runtime stays tolerant.
313
+ * A record is read the way a push reads it (`uwx/entity-source.js`), and a markdown
314
+ * record's body is the value of its schema's content body field unless its
315
+ * frontmatter sets one — which is what a push sends it as (`uwx/records.js`).
289
316
  *
290
- * ⛔ A standard schema for a concept MUST be authored in the ITEM vocabulary —
291
- * `title`, `paragraphs`, and the rest of the parsed shape — because that is what
292
- * a concept block always produces. An `@std/faq` written as `{ question, answer }`
293
- * could only be checked with a per-concept field mapping, which is the forbidden
294
- * registry arriving by the back door. Author the schema to match the parse, or
295
- * do not ship the schema.
317
+ * A folder whose schema resolves to nothing is left out in silence: its records are
318
+ * schema-less, delivered as files, and the push says so itself.
296
319
  *
297
- * ⛔ AND FOR A PROSE CONCEPT, NO FACET CAN FIRE AT ALL — so do not write an
298
- * `@std` schema for one. Measured 2026-07-30:
299
- *
300
- * - `required` is inert. The item vocabulary is TOTAL — `flattenGroup` fills
301
- * every field it declares, so a titleless item has `title: ''` rather than
302
- * no title, and `required` fires only on absent or null. "The author
303
- * actually wrote a question" is not expressible.
304
- * - `type` cannot fail either. Inside a concept block `title` is always a
305
- * string (never an array — `alwaysItems` suppresses the same-level merge
306
- * that would make one) and `paragraphs` is always an array of strings.
307
- * - which leaves `enum` / `format`, and neither has a natural application to
308
- * a question or an answer. The test suite had to invent `format: 'url'` on
309
- * a question to make anything fire — that is the tell, not a fixture quirk.
310
- *
311
- * The mechanism still earns its place, but it is waiting for a different shape:
312
- * a concept that carries a tagged DATA BLOCK. Verified that one reaches the item
313
- * — ```` ```md:steps ```` holding a ```` ```yaml:meta ```` gives
314
- * `items[0].data.meta` — and there `required` fires when an author omits the
315
- * block, `enum` constrains a status, `format` constrains a duration. That is the
316
- * trigger to write a schema. Until then the frontend holds the concept names and
317
- * their shapes, which is where they belong: its extension encodes the shape
318
- * executably, and a `standard/faq.js` in `@uniweb/schemas` whose only consumer is
319
- * that app would be this framework stating which concepts exist — the registry
320
- * this design forbids, spelled as a filename instead of a switch.
321
- *
322
- * Note on resolution: this deliberately does NOT go through `resolveSchemaRef`,
323
- * which resolves a package from a FOUNDATION's node_modules and throws when a
324
- * ref is unknown. Neither fits — a concept block needs no foundation (so this
325
- * works on a link-mode site whose foundation is a registry ref with nothing
326
- * local), and an unresolved tag must be silent rather than an error. So the
327
- * package is resolved from this build's own graph, where it is an
328
- * optionalDependency, exactly as `i18n/records.js` resolves it.
329
- *
330
- * @param {Object} site - collected site content (`{ pages }`)
331
- * @returns {Promise<{ violations: Array, schemas: Set<string>, checked: number }>}
320
+ * @returns {Promise<{ violations: object[], setupErrors: object[], schemas: Set<string>, checked: number }>}
332
321
  */
333
- export async function validateConceptBlocks(site) {
334
- const empty = { violations: [], schemas: new Set(), checked: 0 }
335
-
336
- const parse = await loadSemanticParser()
337
- if (!parse) return empty // no parser available — nothing to derive items from
338
-
339
- const standards = await loadStandardSchemas()
340
- if (!standards) return empty // @uniweb/schemas absent — nothing to check against
341
-
342
- const violations = []
343
- const schemasSeen = new Set()
344
- let checked = 0
345
-
346
- for (const page of site.pages || []) {
347
- walkSections(page.sections || [], (section) => {
348
- const doc = section.content
349
- if (doc?.type !== 'doc') return
350
-
351
- for (const node of conceptBlockNodes(doc)) {
352
- const tag = node.attrs?.tag
353
- if (!tag) continue
354
-
355
- const raw = standards(tag)
356
- if (!raw) continue // no `@std/<tag>` — say nothing, by design
357
-
358
- let schema
359
- try {
360
- schema = validateAndNormalizeSchema(raw, `@std/${tag}`)
361
- } catch {
362
- continue // a malformed standard schema is that package's problem
322
+ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, checked }) {
323
+ const out = { violations: [], setupErrors: [], schemas: new Set(), checked: 0 }
324
+ const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
325
+ for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
326
+ const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
327
+ // An entity of a list-rooted schema too: it holds the list under its section's key.
328
+ // ⛔ Until 2026-09-24 such a folder was skipped here without a word.
329
+ if (!schema) continue
330
+ for (const pooled of entities) {
331
+ let records
332
+ try {
333
+ records = await readEntityFile(pooled.absPath)
334
+ } catch (err) {
335
+ out.setupErrors.push({ file: pooled.relPath, message: err.message, users: [] })
336
+ continue
337
+ }
338
+ for (const r of records) {
339
+ if (!r.slug || checked.has(recordKey(ref, r.slug))) continue
340
+ out.checked++
341
+ out.schemas.add(ref)
342
+ for (const finding of validateItem(schema, withBody(schema, r))) {
343
+ out.violations.push({ file: pooled.relPath, schema: ref, item: r.slug, users: [], ...finding })
363
344
  }
364
- if (!isStaticallyCheckable(schema)) continue
365
-
366
- schemasSeen.add(`@std/${tag}`)
367
- const { items } = parse({ type: 'doc', content: node.content || [] }, { alwaysItems: true })
368
-
369
- items.forEach((item, idx) => {
370
- checked++
371
- for (const finding of validateItem(schema, item)) {
372
- violations.push({
373
- file: `${page.route || '/'} › ${section.type || 'section'} › md:${tag}`,
374
- schema: `@std/${tag}`,
375
- item: `item ${idx + 1}`,
376
- users: [{ route: page.route, section: section.type, key: tag }],
377
- ...finding,
378
- })
379
- }
380
- })
381
345
  }
382
- })
346
+ }
383
347
  }
348
+ return out
349
+ }
384
350
 
385
- return { violations, schemas: schemasSeen, checked }
351
+ // The schema a records folder names — the one a section binds if any does, else
352
+ // resolved the way every other ref is. Null when nothing resolves.
353
+ async function schemaForRecords(ref, { srcDir, dataSchemas }) {
354
+ if (dataSchemas[ref]) return dataSchemas[ref]
355
+ try {
356
+ return (await buildDataSchemaMap([ref], { srcDir }))[ref] || null
357
+ } catch {
358
+ return null
359
+ }
386
360
  }
387
361
 
388
- /** Every concept block in a doc, including any nested inside a container. */
389
- function conceptBlockNodes(doc) {
390
- return nodesOfType(doc, 'concept_block')
362
+ // A record as a push sends it: a markdown body fills the schema's content body field
363
+ // when the frontmatter does not set it.
364
+ function withBody(schema, r) {
365
+ const record = { ...(r.data || {}) }
366
+ if (typeof r.body !== 'string' || r.body.trim() === '') return record
367
+ const key = Object.entries(flatRecordFields(schema) || {}).find(([, f]) => isContentBodyField(f))?.[0]
368
+ if (key && record[key] === undefined) record[key] = r.body
369
+ return record
391
370
  }
392
371
 
393
372
  function nodesOfType(doc, type) {
@@ -414,11 +393,9 @@ function nodesOfType(doc, type) {
414
393
  * tagged block fills was never applied to anything. `@std/form` existed for
415
394
  * exactly this and had never run outside its own contract test.
416
395
  *
417
- * Unlike concept blocks (pass 3), the join here is NOT by convention. A concept
418
- * block resolves `md:faq` → `@std/faq` mechanically, which is why that pass must
419
- * stay silent when no such schema exists. This one uses the binding the component
420
- * actually declared, so there is no naming rule and no registry — a tag nobody
421
- * bound is simply not governed, and says nothing.
396
+ * The join is the binding the component actually declared, so there is no naming
397
+ * rule and no registry — a tag nobody bound is simply not governed, and says
398
+ * nothing.
422
399
  *
423
400
  * The value needs no parsing: a tagged fence lands as a `dataBlock` node with its
424
401
  * parsed value already on `attrs.data`, and a body that FAILED to parse never
@@ -481,28 +458,6 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
481
458
  return { violations, schemas, checked, deferred }
482
459
  }
483
460
 
484
- /** `parseContent`, or null when the parser is not installed. */
485
- async function loadSemanticParser() {
486
- try {
487
- const mod = await import('@uniweb/semantic-parser')
488
- return typeof mod.parseContent === 'function' ? mod.parseContent : null
489
- } catch {
490
- return null
491
- }
492
- }
493
-
494
- /** A `(name) => schema | undefined` lookup over `@std`, or null when absent. */
495
- async function loadStandardSchemas() {
496
- try {
497
- const mod = await import('@uniweb/schemas')
498
- if (typeof mod.getSchema === 'function') return (name) => mod.getSchema(name)
499
- const table = mod.schemas ?? mod.default
500
- return table ? (name) => table[name] : null
501
- } catch {
502
- return null
503
- }
504
- }
505
-
506
461
  /**
507
462
  * The data inputs reaching a section, deduped by key — the fetches of its levels, most
508
463
  * specific first; when two levels share a key, the nearer one wins. What each input fills