@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.
@@ -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
- // `/data/<name>.json` → a declared collection? Use the compiled records.
406
- const name = path.replace(/^\/?data\//, '').replace(/\.json$/i, '')
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]