@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.
- package/package.json +5 -5
- package/src/index.js +0 -1
- package/src/resolve-data-schema.js +28 -5
- package/src/schema.js +7 -4
- package/src/uwx/backfill.js +30 -68
- package/src/uwx/emit-surface.json +1 -1
- package/src/uwx/entity-document.js +22 -1
- package/src/uwx/folder.js +13 -9
- package/src/uwx/index.js +1 -1
- package/src/uwx/project-writer.js +49 -6
- package/src/uwx/records-project.js +31 -14
- package/src/uwx/records.js +135 -35
- package/src/uwx/self-scope.js +1 -1
- package/src/uwx/site.js +6 -6
- package/src/uwx/sync-package.js +12 -7
- package/src/validate-data.js +108 -153
package/src/validate-data.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
|
271
|
-
*
|
|
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
|
-
*
|
|
288
|
-
*
|
|
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
|
-
*
|
|
291
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
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
|