@uniweb/build 0.15.13 → 0.16.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 +9 -8
- package/src/i18n/collections.js +23 -52
- package/src/i18n/data-strings.js +114 -0
- package/src/i18n/extract.js +150 -52
- package/src/i18n/merge.js +34 -0
- package/src/index.js +1 -0
- package/src/prerender.js +14 -30
- package/src/site/build-site-data.js +23 -4
- package/src/site/collection-processor.js +85 -7
- package/src/site/content-collector.js +4 -3
- package/src/site/data-ball.js +2 -1
- package/src/site/data-fetcher.js +2 -2
- package/src/site/head-markers.js +16 -6
- package/src/site/plugin.js +112 -15
- package/src/uwx/locale-sync.js +37 -16
- package/src/uwx/site.js +10 -7
- package/src/validate-data.js +167 -3
package/src/validate-data.js
CHANGED
|
@@ -29,8 +29,9 @@ import { readFile } from 'node:fs/promises'
|
|
|
29
29
|
import { existsSync } from 'node:fs'
|
|
30
30
|
import { join, resolve, basename } from 'node:path'
|
|
31
31
|
import yaml from 'js-yaml'
|
|
32
|
+
import { collectionNameFromUrl } from '@uniweb/core'
|
|
32
33
|
|
|
33
|
-
import { SCALAR_KINDS, FORMAT_TYPES } from './resolve-data-schema.js'
|
|
34
|
+
import { SCALAR_KINDS, FORMAT_TYPES, validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
34
35
|
import { buildSchema } from './schema.js'
|
|
35
36
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
36
37
|
import { collectSiteContent } from './site/content-collector.js'
|
|
@@ -352,6 +353,13 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
352
353
|
})
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
// Pass 3 — concept blocks, which join to a schema by CONVENTION rather than
|
|
357
|
+
// by a foundation binding. Additive and silent unless a schema resolves.
|
|
358
|
+
const concepts = await validateConceptBlocks(site)
|
|
359
|
+
violations.push(...concepts.violations)
|
|
360
|
+
for (const ref of concepts.schemas) schemasSeen.add(ref)
|
|
361
|
+
recordCount += concepts.checked
|
|
362
|
+
|
|
355
363
|
return {
|
|
356
364
|
violations,
|
|
357
365
|
deferred,
|
|
@@ -365,6 +373,161 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
365
373
|
}
|
|
366
374
|
}
|
|
367
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Check each ```md:<tag> concept block against `@std/<tag>`, when that schema
|
|
378
|
+
* exists.
|
|
379
|
+
*
|
|
380
|
+
* THREE PROPERTIES MAKE THIS SAFE, and all three have to hold:
|
|
381
|
+
*
|
|
382
|
+
* 1. It adds NO REGISTRY. The resolution is mechanical — `md:faq` → `@std/faq`,
|
|
383
|
+
* the same `@std` → `@uniweb/schemas` mapping every other ref uses. What the
|
|
384
|
+
* framework gains is a naming convention; no code branches on the value of a
|
|
385
|
+
* tag, and nothing here knows which concepts exist. A hardcoded list of
|
|
386
|
+
* concept names is the thing this whole design exists to avoid, and it would
|
|
387
|
+
* arrive through this door if the check needed to know what `faq` means.
|
|
388
|
+
*
|
|
389
|
+
* 2. It never touches SHAPE. A concept block's shape comes from its fence,
|
|
390
|
+
* unconditionally. This runs after the parse and changes nothing: a block
|
|
391
|
+
* with no resolvable schema still parses, still delivers items, still
|
|
392
|
+
* renders. The schema is a check, never a gate.
|
|
393
|
+
*
|
|
394
|
+
* 3. It never fails at RENDER. Findings only — this whole module is a pre-live
|
|
395
|
+
* dev/CI gate and the runtime stays tolerant.
|
|
396
|
+
*
|
|
397
|
+
* ⛔ A standard schema for a concept MUST be authored in the ITEM vocabulary —
|
|
398
|
+
* `title`, `paragraphs`, and the rest of the parsed shape — because that is what
|
|
399
|
+
* a concept block always produces. An `@std/faq` written as `{ question, answer }`
|
|
400
|
+
* could only be checked with a per-concept field mapping, which is the forbidden
|
|
401
|
+
* registry arriving by the back door. Author the schema to match the parse, or
|
|
402
|
+
* do not ship the schema.
|
|
403
|
+
*
|
|
404
|
+
* ⛔ AND FOR A PROSE CONCEPT, NO FACET CAN FIRE AT ALL — so do not write an
|
|
405
|
+
* `@std` schema for one. Measured 2026-07-30:
|
|
406
|
+
*
|
|
407
|
+
* - `required` is inert. The item vocabulary is TOTAL — `flattenGroup` fills
|
|
408
|
+
* every field it declares, so a titleless item has `title: ''` rather than
|
|
409
|
+
* no title, and `required` fires only on absent or null. "The author
|
|
410
|
+
* actually wrote a question" is not expressible.
|
|
411
|
+
* - `type` cannot fail either. Inside a concept block `title` is always a
|
|
412
|
+
* string (never an array — `alwaysItems` suppresses the same-level merge
|
|
413
|
+
* that would make one) and `paragraphs` is always an array of strings.
|
|
414
|
+
* - which leaves `enum` / `format`, and neither has a natural application to
|
|
415
|
+
* a question or an answer. The test suite had to invent `format: 'url'` on
|
|
416
|
+
* a question to make anything fire — that is the tell, not a fixture quirk.
|
|
417
|
+
*
|
|
418
|
+
* The mechanism still earns its place, but it is waiting for a different shape:
|
|
419
|
+
* a concept that carries a tagged DATA BLOCK. Verified that one reaches the item
|
|
420
|
+
* — ```` ```md:steps ```` holding a ```` ```yaml:meta ```` gives
|
|
421
|
+
* `items[0].data.meta` — and there `required` fires when an author omits the
|
|
422
|
+
* block, `enum` constrains a status, `format` constrains a duration. That is the
|
|
423
|
+
* trigger to write a schema. Until then the frontend holds the concept names and
|
|
424
|
+
* their shapes, which is where they belong: its extension encodes the shape
|
|
425
|
+
* executably, and a `standard/faq.js` in `@uniweb/schemas` whose only consumer is
|
|
426
|
+
* that app would be this framework stating which concepts exist — the registry
|
|
427
|
+
* this design forbids, spelled as a filename instead of a switch.
|
|
428
|
+
*
|
|
429
|
+
* Note on resolution: this deliberately does NOT go through `resolveSchemaRef`,
|
|
430
|
+
* which resolves a package from a FOUNDATION's node_modules and throws when a
|
|
431
|
+
* ref is unknown. Neither fits — a concept block needs no foundation (so this
|
|
432
|
+
* works on a link-mode site whose foundation is a registry ref with nothing
|
|
433
|
+
* local), and an unresolved tag must be silent rather than an error. So the
|
|
434
|
+
* package is resolved from this build's own graph, where it is an
|
|
435
|
+
* optionalDependency, exactly as `i18n/collections.js` resolves it.
|
|
436
|
+
*
|
|
437
|
+
* @param {Object} site - collected site content (`{ pages }`)
|
|
438
|
+
* @returns {Promise<{ violations: Array, schemas: Set<string>, checked: number }>}
|
|
439
|
+
*/
|
|
440
|
+
export async function validateConceptBlocks(site) {
|
|
441
|
+
const empty = { violations: [], schemas: new Set(), checked: 0 }
|
|
442
|
+
|
|
443
|
+
const parse = await loadSemanticParser()
|
|
444
|
+
if (!parse) return empty // no parser available — nothing to derive items from
|
|
445
|
+
|
|
446
|
+
const standards = await loadStandardSchemas()
|
|
447
|
+
if (!standards) return empty // @uniweb/schemas absent — nothing to check against
|
|
448
|
+
|
|
449
|
+
const violations = []
|
|
450
|
+
const schemasSeen = new Set()
|
|
451
|
+
let checked = 0
|
|
452
|
+
|
|
453
|
+
for (const page of site.pages || []) {
|
|
454
|
+
walkSections(page.sections || [], (section) => {
|
|
455
|
+
const doc = section.content
|
|
456
|
+
if (doc?.type !== 'doc') return
|
|
457
|
+
|
|
458
|
+
for (const node of conceptBlockNodes(doc)) {
|
|
459
|
+
const tag = node.attrs?.tag
|
|
460
|
+
if (!tag) continue
|
|
461
|
+
|
|
462
|
+
const raw = standards(tag)
|
|
463
|
+
if (!raw) continue // no `@std/<tag>` — say nothing, by design
|
|
464
|
+
|
|
465
|
+
let schema
|
|
466
|
+
try {
|
|
467
|
+
schema = validateAndNormalizeSchema(raw, `@std/${tag}`)
|
|
468
|
+
} catch {
|
|
469
|
+
continue // a malformed standard schema is that package's problem
|
|
470
|
+
}
|
|
471
|
+
if (!isStaticallyCheckable(schema)) continue
|
|
472
|
+
|
|
473
|
+
schemasSeen.add(`@std/${tag}`)
|
|
474
|
+
const { items } = parse({ type: 'doc', content: node.content || [] }, { alwaysItems: true })
|
|
475
|
+
|
|
476
|
+
items.forEach((item, idx) => {
|
|
477
|
+
checked++
|
|
478
|
+
for (const finding of validateItem(schema, item)) {
|
|
479
|
+
violations.push({
|
|
480
|
+
file: `${page.route || '/'} › ${section.type || 'section'} › md:${tag}`,
|
|
481
|
+
schema: `@std/${tag}`,
|
|
482
|
+
item: `item ${idx + 1}`,
|
|
483
|
+
users: [{ route: page.route, section: section.type, key: tag }],
|
|
484
|
+
...finding,
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return { violations, schemas: schemasSeen, checked }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Every concept block in a doc, including any nested inside a container. */
|
|
496
|
+
function conceptBlockNodes(doc) {
|
|
497
|
+
const out = []
|
|
498
|
+
const walk = (nodes) => {
|
|
499
|
+
for (const node of nodes || []) {
|
|
500
|
+
if (!node) continue
|
|
501
|
+
if (node.type === 'concept_block') out.push(node)
|
|
502
|
+
else if (Array.isArray(node.content)) walk(node.content)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
walk(doc?.content)
|
|
506
|
+
return out
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** `parseContent`, or null when the parser is not installed. */
|
|
510
|
+
async function loadSemanticParser() {
|
|
511
|
+
try {
|
|
512
|
+
const mod = await import('@uniweb/semantic-parser')
|
|
513
|
+
return typeof mod.parseContent === 'function' ? mod.parseContent : null
|
|
514
|
+
} catch {
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** A `(name) => schema | undefined` lookup over `@std`, or null when absent. */
|
|
520
|
+
async function loadStandardSchemas() {
|
|
521
|
+
try {
|
|
522
|
+
const mod = await import('@uniweb/schemas')
|
|
523
|
+
if (typeof mod.getSchema === 'function') return (name) => mod.getSchema(name)
|
|
524
|
+
const table = mod.schemas ?? mod.default
|
|
525
|
+
return table ? (name) => table[name] : null
|
|
526
|
+
} catch {
|
|
527
|
+
return null
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
368
531
|
/**
|
|
369
532
|
* The data inputs available to a section, deduped by key. A section receives
|
|
370
533
|
* its own fetch plus any inherited page-level and site-level fetch (default-on
|
|
@@ -402,8 +565,9 @@ function walkSections(sections, visit) {
|
|
|
402
565
|
* (hand-authored data) is read from disk. Either way no prior build is needed.
|
|
403
566
|
*/
|
|
404
567
|
async function resolveRecords(path, { collections, siteRoot }) {
|
|
405
|
-
//
|
|
406
|
-
|
|
568
|
+
// A compiled-collection URL → a declared collection? Use the compiled
|
|
569
|
+
// records. Anything else falls through to the file read below.
|
|
570
|
+
const name = collectionNameFromUrl(path)
|
|
407
571
|
let records
|
|
408
572
|
if (Object.prototype.hasOwnProperty.call(collections, name)) {
|
|
409
573
|
records = collections[name]
|