@uniweb/build 0.60.0 → 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.
- package/package.json +4 -4
- package/src/index.js +0 -1
- package/src/validate-data.js +44 -170
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
61
61
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
62
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/schemas": "^0.6.0",
|
|
63
|
+
"@uniweb/theming": "^0.1.15",
|
|
63
64
|
"@uniweb/projections": "^0.7.3",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
65
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
68
|
"@uniweb/runtime": "^0.27.4"
|
package/src/index.js
CHANGED
package/src/validate-data.js
CHANGED
|
@@ -31,7 +31,7 @@ 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, validateBound, flatRecordFields, rootListSection } from '@uniweb/schemas/conform'
|
|
35
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
|
|
@@ -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,6 +296,16 @@ 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`).
|
|
@@ -304,7 +324,9 @@ async function validateRecordFiles(siteRoot, { srcDir, dataSchemas, paths, check
|
|
|
304
324
|
const pool = await readEntityPool(siteRoot, { dir: resolveRecordsDir(siteRoot, paths).rel })
|
|
305
325
|
for (const [ref, entities] of groupPoolBySchema(pool.entities)) {
|
|
306
326
|
const schema = await schemaForRecords(ref, { srcDir, dataSchemas })
|
|
307
|
-
|
|
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
|
|
308
330
|
for (const pooled of entities) {
|
|
309
331
|
let records
|
|
310
332
|
try {
|
|
@@ -347,130 +369,6 @@ function withBody(schema, r) {
|
|
|
347
369
|
return record
|
|
348
370
|
}
|
|
349
371
|
|
|
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
|
-
})
|
|
464
|
-
}
|
|
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')
|
|
472
|
-
}
|
|
473
|
-
|
|
474
372
|
function nodesOfType(doc, type) {
|
|
475
373
|
const out = []
|
|
476
374
|
const walk = (nodes) => {
|
|
@@ -495,11 +393,9 @@ function nodesOfType(doc, type) {
|
|
|
495
393
|
* tagged block fills was never applied to anything. `@std/form` existed for
|
|
496
394
|
* exactly this and had never run outside its own contract test.
|
|
497
395
|
*
|
|
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.
|
|
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.
|
|
503
399
|
*
|
|
504
400
|
* The value needs no parsing: a tagged fence lands as a `dataBlock` node with its
|
|
505
401
|
* parsed value already on `attrs.data`, and a body that FAILED to parse never
|
|
@@ -562,28 +458,6 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
|
|
|
562
458
|
return { violations, schemas, checked, deferred }
|
|
563
459
|
}
|
|
564
460
|
|
|
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
461
|
/**
|
|
588
462
|
* The data inputs reaching a section, deduped by key — the fetches of its levels, most
|
|
589
463
|
* specific first; when two levels share a key, the nearer one wins. What each input fills
|