@uniweb/build 0.66.1 → 0.67.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.66.1",
3
+ "version": "0.67.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,14 +58,14 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.5",
61
- "@uniweb/schemas": "^0.8.0",
62
- "@uniweb/projections": "^0.7.6",
63
61
  "@uniweb/content-writer": "^0.3.4",
62
+ "@uniweb/schemas": "^0.8.1",
64
63
  "@uniweb/semantic-parser": "^1.4.1",
64
+ "@uniweb/projections": "^0.7.7",
65
65
  "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.8"
68
+ "@uniweb/runtime": "^0.27.10"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.32.0",
78
- "@uniweb/runtime": "^0.27.8"
77
+ "@uniweb/core": "^0.32.2",
78
+ "@uniweb/runtime": "^0.27.10"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/schema.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from 'node:crypto'
16
16
  import { isFontVar } from '@uniweb/theming'
17
17
  import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
- import { inferTitle } from './utils/infer-title.js'
19
+ import { describeChildren, describeVisuals } from '@uniweb/schemas/component'
20
20
  import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
21
21
  import {
22
22
  composeSupports,
@@ -547,41 +547,35 @@ function hasEntryFile(dirPath, dirName) {
547
547
  /**
548
548
  * Create an implicit empty meta for a section type discovered without meta.js
549
549
  */
550
- function createImplicitMeta(name) {
551
- return { title: inferTitle(name), titleInferred: true }
550
+ function createImplicitMeta() {
551
+ return {}
552
552
  }
553
553
 
554
554
  /**
555
- * Build a component entry with title inference applied.
555
+ * Build a component entry: the `meta.js` default export, plus the build's `name`
556
+ * and `path`.
556
557
  *
557
- * ⭐ `titleInferred` SAYS THE TITLE IS OURS, NOT THE AUTHOR'S, and without it a
558
- * consumer cannot tell. We fill `title` from the component name whenever
559
- * `meta.js` declares none, so `title` is ALWAYS present — which means an editor
560
- * showing section names has no way to know that `"Hero"` is a string the build
561
- * invented rather than one a developer chose.
558
+ * ⭐ `title` IS THERE ONLY WHEN THE DEVELOPER WROTE ONE, so its absence says the
559
+ * component is unnamed. That difference decides whether a name can be translated:
560
+ * an authored title is the foundation's own words and is shown verbatim in every UI
561
+ * language, while for an unnamed component a consumer has better strings — its
562
+ * `family`'s translated label, else `inferTitle(name)` from `@uniweb/schemas/component`,
563
+ * the words this build used to write in.
562
564
  *
563
- * ⛔ THAT DIFFERENCE DECIDES WHETHER THE NAME CAN BE TRANSLATED. An authored
564
- * title is the foundation's own words and must be shown verbatim in every UI
565
- * language. An inferred one is a placeholder, and a consumer that knows the
566
- * section's `family` has a better string available — one it can localize. A
567
- * foundation that never named its Hero would otherwise ship the English
568
- * `"Hero"` to every author in the world.
569
- *
570
- * ⚖️ The build itself reads neither field. This is a statement ABOUT the title,
571
- * for whoever renders it.
565
+ * ⛔ Until 2026-09-27 the build filled `title` from the name whenever `meta.js`
566
+ * declared none, and added `titleInferred: true` to restore the fact the filling-in
567
+ * erased. Consumers derive the title where they use it instead [Diego, 2026-09-27].
572
568
  */
573
569
  function buildComponentEntry(name, relativePath, meta) {
574
- const entry = {
570
+ // ⭐ `name` and `path` are the build's facts, so they are set AFTER the spread: a
571
+ // `meta.js` declaring either cannot change them. ⛔ Until 2026-09-27 they came first,
572
+ // and a `meta.js` `name` became the entry's `name` — which an editor reads as the
573
+ // section type — while the schema stayed keyed by the folder's name.
574
+ return {
575
+ ...meta,
575
576
  name,
576
577
  path: relativePath,
577
- ...meta,
578
- }
579
- // Apply title inference if meta has no explicit title
580
- if (!entry.title) {
581
- entry.title = inferTitle(name)
582
- entry.titleInferred = true
583
578
  }
584
- return entry
585
579
  }
586
580
 
587
581
  /**
@@ -637,7 +631,7 @@ async function discoverSectionsInPath(srcDir, sectionsRelPath) {
637
631
  const name = basename(entry.name, ext)
638
632
  if (!isComponentFileName(name)) continue
639
633
 
640
- const meta = createImplicitMeta(name)
634
+ const meta = createImplicitMeta()
641
635
  components[name] = {
642
636
  ...buildComponentEntry(name, sectionsRelPath, meta),
643
637
  // Bare file: the entry file IS the file itself (not inside a subdirectory)
@@ -662,7 +656,7 @@ async function discoverSectionsInPath(srcDir, sectionsRelPath) {
662
656
  components[entry.name] = buildComponentEntry(entry.name, relativePath, result.meta)
663
657
  } else if (hasEntryFile(dirPath, entry.name)) {
664
658
  // No meta.js but has entry file — implicit section type at root
665
- components[entry.name] = buildComponentEntry(entry.name, relativePath, createImplicitMeta(entry.name))
659
+ components[entry.name] = buildComponentEntry(entry.name, relativePath, createImplicitMeta())
666
660
  }
667
661
  }
668
662
 
@@ -730,7 +724,7 @@ export async function discoverLayoutsInPath(srcDir, layoutsRelPath = LAYOUTS_PAT
730
724
  const name = basename(entry.name, ext)
731
725
  if (!isLayoutName(name)) continue
732
726
 
733
- const meta = createImplicitMeta(name)
727
+ const meta = createImplicitMeta()
734
728
  layouts[name] = {
735
729
  ...buildComponentEntry(name, layoutsRelPath, meta),
736
730
  entryFile: entry.name,
@@ -750,7 +744,7 @@ export async function discoverLayoutsInPath(srcDir, layoutsRelPath = LAYOUTS_PAT
750
744
  if (result.meta.hidden) continue
751
745
  layouts[entry.name] = buildComponentEntry(entry.name, relativePath, result.meta)
752
746
  } else if (hasEntryFile(dirPath, entry.name)) {
753
- layouts[entry.name] = buildComponentEntry(entry.name, relativePath, createImplicitMeta(entry.name))
747
+ layouts[entry.name] = buildComponentEntry(entry.name, relativePath, createImplicitMeta())
754
748
  }
755
749
  }
756
750
 
@@ -936,6 +930,45 @@ function reportSupports(srcDir, authored, derived, emitted) {
936
930
  }
937
931
  }
938
932
 
933
+ /**
934
+ * Warn about `children` and `visuals` declarations an editor cannot use — read with
935
+ * `@uniweb/schemas`, the grammar the editor reads them with. The entry is emitted as
936
+ * written either way; these are warnings, never refusals.
937
+ *
938
+ * ⭐ A `children.types` entry must name one of this foundation's section types. A hidden
939
+ * one is not a section type at all, and a child of a type nothing provides renders
940
+ * "Component not found". The one false alarm is a type an extension provides, which
941
+ * this foundation cannot see — the message says so.
942
+ *
943
+ * @param {Object} components - discovered section types, by name
944
+ */
945
+ export function reportPlacementDeclarations(components) {
946
+ for (const [name, entry] of Object.entries(components)) {
947
+ const children = describeChildren(entry)
948
+ const visuals = describeVisuals(entry)
949
+ for (const problem of [...children.problems, ...visuals.problems]) {
950
+ console.warn(`Warning: ${name} (meta.js): ${problem}`)
951
+ }
952
+ for (const type of children.types || []) {
953
+ if (components[type]) continue
954
+ console.warn(
955
+ `Warning: ${name} (meta.js): children.types names "${type}", which is not a section type of this ` +
956
+ `foundation — hidden, misspelled, or provided by an extension. A child section of a type nothing ` +
957
+ `provides renders "Component not found".`
958
+ )
959
+ }
960
+ // `grid` is a section key framework reserves for the layout of a section's children,
961
+ // so a param by that name never receives what an author writes as `grid:`.
962
+ if (entry.params && typeof entry.params === 'object' && Object.hasOwn(entry.params, 'grid')) {
963
+ console.warn(
964
+ `Warning: ${name} (meta.js): a param named "grid" never receives the author's grid: — it is a section ` +
965
+ `key reserved for the layout of child sections. Offer layouts with children: { grid: [...] } and ` +
966
+ `lay the children out with kit's ChildGrid, or rename the param.`
967
+ )
968
+ }
969
+ }
970
+ }
971
+
939
972
  /**
940
973
  * Build complete schema for a foundation
941
974
  * Returns { _self: { identity + config }, ComponentName: componentMeta, ... }
@@ -959,6 +992,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
959
992
 
960
993
  // Discover section types
961
994
  const components = await discoverComponents(srcDir, sectionPaths)
995
+ reportPlacementDeclarations(components)
962
996
 
963
997
  // Resolve the data schemas the foundation DEFINES — every file in its `schemas/`
964
998
  // folder (`ownSchemaRefs`) — and the ones its section bindings reference, carried
@@ -60,8 +60,8 @@ import {
60
60
  *
61
61
  * ⇒ The rule stands on its own: we do not know which consumers
62
62
  * re-derive, so we always ship enough for the ones that do.
63
- * - `data/<collection>.json` (+ per-record files for `deferred:`
64
- * collections) — same shape `processQueries` produces today.
63
+ * - `data/<collection>.json` (+ each record's own file,
64
+ * `data/<collection>/<$name>.json`) — same shape `processQueries` produces today.
65
65
  * - `records/<path under the records directory>` — the records' co-located assets, where
66
66
  * the compiled records point.
67
67
  * - `assets/<media>` — processed images / video posters / PDF
@@ -39,6 +39,7 @@ import { resolveExtensionUrls } from './extension-urls.js'
39
39
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
40
40
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery, currentFor } from '@uniweb/core'
41
41
  import { parseFrontmatter } from '../utils/frontmatter.js'
42
+ import { parseGrid } from '@uniweb/schemas/grid'
42
43
 
43
44
  // Try to import content-reader, fall back to simplified parser
44
45
  let markdownToProseMirror
@@ -456,60 +457,6 @@ async function readYamlFile(filePath) {
456
457
  }
457
458
  }
458
459
 
459
- /**
460
- * Extract inset references from a ProseMirror document.
461
- *
462
- * Walks the document recursively for `inset_ref` nodes (produced by
463
- * content-reader for the `![alt](@ComponentName){params}` /
464
- * `[text](@ComponentName){params}` / `[@key]{params}` forms). Each ref
465
- * is removed and replaced in-place with an `inset_placeholder` node
466
- * carrying a unique refId. The extracted refs are returned as an array.
467
- *
468
- * Inline insets (mid-paragraph) are kept as inline placeholders so the
469
- * paragraph's text flow is preserved; block-level insets (own line)
470
- * stay at the document root. Both share the same refId/getInset(refId)
471
- * lookup machinery — only the position differs.
472
- *
473
- * @param {Object} doc - ProseMirror document (mutated in place)
474
- * @returns {Array} Array of { refId, type, params, description, embedKind }
475
- */
476
- function extractInsets(doc) {
477
- if (!doc?.content || !Array.isArray(doc.content)) return []
478
-
479
- const insets = []
480
- let refIndex = 0
481
-
482
- function visit(nodes) {
483
- if (!Array.isArray(nodes)) return
484
- for (let i = 0; i < nodes.length; i++) {
485
- const node = nodes[i]
486
- if (!node) continue
487
- if (node.type === 'inset_ref') {
488
- const { component, alt, embedKind, ...params } = node.attrs || {}
489
- const refId = `inset_${refIndex++}`
490
- insets.push({
491
- refId,
492
- type: component,
493
- embedKind: embedKind || 'visual',
494
- params: Object.keys(params).length > 0 ? params : {},
495
- title: alt || null,
496
- })
497
- nodes[i] = {
498
- type: 'inset_placeholder',
499
- attrs: { refId, embedKind: embedKind || 'visual' },
500
- }
501
- continue
502
- }
503
- if (Array.isArray(node.content)) {
504
- visit(node.content)
505
- }
506
- }
507
- }
508
-
509
- visit(doc.content)
510
- return insets
511
- }
512
-
513
460
  /**
514
461
  * Check if a filename uses the @ prefix (child section convention).
515
462
  * @-prefixed files are excluded from auto-discovered top-level sections —
@@ -1065,13 +1012,28 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1065
1012
  // `query`, `fetch` and `data` are never params: `query:` / `fetch:` declare the
1066
1013
  // section's own data, and a leftover `data:` is refused (`declaredFetch`).
1067
1014
  const { type, preset, input, props, fetch, query, data, id: frontmatterId, ...params } = frontMatter
1015
+ // ⭐ `grid` — the layout of the section's child sections, `3` or `'40/60'` [Diego,
1016
+ // 2026-09-27] — is a name framework reserves, and it stays in the params like
1017
+ // `background`: that is where it is stored and synced, with no field of its own.
1018
+ // `@uniweb/core` lifts it to `block.grid` (a component never receives it as a param)
1019
+ // and kit's `ChildGrid` lays the children out. ⛔ For a few hours on 2026-09-27 it was
1020
+ // taken out of the params into a field the sync wire could not carry.
1021
+ if (params.grid !== undefined && !parseGrid(params.grid)) {
1022
+ console.warn(
1023
+ `[content-collector] ${relative(siteRoot, filePath)}: grid: ${JSON.stringify(params.grid)} is not a layout — ` +
1024
+ `write a column count (grid: 3) or relative widths (grid: '40/60'). The component's own layout applies.`
1025
+ )
1026
+ }
1068
1027
 
1069
- // Convert markdown to ProseMirror
1028
+ // Convert markdown to ProseMirror. ⭐ Insets stay as the author wrote them —
1029
+ // `inset_ref`, `inset_block` — and `@uniweb/core` lifts both when it builds the
1030
+ // section's Block. ⛔ Until 2026-09-27 this extracted each leaf `inset_ref` into a
1031
+ // section-level `insets[]` and left a placeholder, so any document that did not
1032
+ // come through here (a free-form translation, a record body) kept raw references
1033
+ // nothing resolved, and the same section's content differed between a build and a
1034
+ // document stored as written.
1070
1035
  const proseMirrorContent = markdownToProseMirror(markdown)
1071
1036
 
1072
- // Extract @ component references → insets (mutates doc)
1073
- const insets = extractInsets(proseMirrorContent)
1074
-
1075
1037
  // `query: team` → `fetch: { query: team }`; a list, one config per name, each under
1076
1038
  // its own `as` — the one helper every level uses. Unrelated to a section type's
1077
1039
  // `meta.js` `data:`, which declares the `content.data` keys a component receives and
@@ -1092,7 +1054,6 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1092
1054
  params: { ...params, ...props },
1093
1055
  content: proseMirrorContent,
1094
1056
  fetch: parseFetchConfig(resolvedFetch, relative(siteRoot, filePath), { level: 'section' }),
1095
- ...(insets.length > 0 ? { insets } : {}),
1096
1057
  subsections: []
1097
1058
  }
1098
1059
 
@@ -2994,7 +2955,6 @@ export {
2994
2955
  parseWildcardArray,
2995
2956
  applyWildcardOrder,
2996
2957
  getDirectChildName,
2997
- extractInsets,
2998
2958
  readYamlFile,
2999
2959
  readFolderConfig,
3000
2960
  isMarkdownFile,
@@ -1335,8 +1335,8 @@ export function siteContentPlugin(options = {}) {
1335
1335
  }
1336
1336
 
1337
1337
  // Handle localized collection data (e.g., /fr/data/articles.json), and a
1338
- // `deferred:` query's per-record files (/fr/data/articles/<slug>.json) — one
1339
- // record each, translated the same way.
1338
+ // query's per-record files (/fr/data/articles/<$name>.json) — one record
1339
+ // each, translated the same way.
1340
1340
  const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/${DATA_DIR}\\/(.+\\.json)$`))
1341
1341
  if (localeDataMatch) {
1342
1342
  const locale = localeDataMatch[1]
@@ -6,8 +6,8 @@
6
6
  * that resolves a data schema syncs as folder entities — content a backend
7
7
  * genuinely consumes, queryable, editable, with a `brief:` for its lean shape.
8
8
  * A **schema-less** collection has no entity model, so its compiled
9
- * `dist/data/<name>.json` (plus any `deferred:` per-record files) is delivered
10
- * as files instead. That fallback tier is all this module is about.
9
+ * `dist/data/<name>.json` (plus its per-record files, `<name>/<$name>.json`) is
10
+ * delivered as files instead. That fallback tier is all this module is about.
11
11
  *
12
12
  * { data: { "<relpath under dist/data>": <json> } } // schema-less only
13
13
  *
@@ -67,7 +67,8 @@ function queryOf(relPath) {
67
67
  }
68
68
 
69
69
  /**
70
- * Assemble the static-data ball from a built site's dist/.
70
+ * Gather the schema-less set from a built site's dist/ — in memory, one entry per
71
+ * file; nothing is bundled (see the header).
71
72
  *
72
73
  * @param {string} distDir - the site's built dist/ directory
73
74
  * @param {string[]} [schemalessNames] - collection names with no data schema (from
@@ -428,8 +428,9 @@ function reinlineInsets(content, insets) {
428
428
  * `preset` / `input` / `fetch` / `id` (`stable_id`); the body is the section's
429
429
  * content (insets re-inlined) serialized to markdown. Idempotent.
430
430
  *
431
- * Note: `$children` (the `@`-nested child sections) are NOT written here — the
432
- * page walk places them as `@`-files plus a `nest:` map. This writes one section.
431
+ * Note: `$children` (a section's child sections) are NOT written here — the page
432
+ * walk (`pageSectionsToFiles`) writes each to its own file and nests it under its
433
+ * parent in `page.yml::sections:`, in stored order. This writes one section.
433
434
  *
434
435
  * @param {object} params
435
436
  * @param {string} params.filePath
package/src/uwx/site.js CHANGED
@@ -312,8 +312,7 @@ function mapSectionData(section) {
312
312
  setIf(data, 'preset', section.preset ?? undefined)
313
313
  setIf(data, 'input', section.input ?? undefined)
314
314
  if (Object.keys(params).length > 0) data.params = params
315
- data.content = section.content
316
- if (section.insets && section.insets.length > 0) data.insets = section.insets
315
+ data.content = section.content // leaf insets in it as written — no `insets` field since 2026-09-27
317
316
  setIf(data, 'fetch', section.fetch ?? undefined)
318
317
  setIf(data, 'background', background)
319
318
  setIf(data, 'theme_override', themeOverride)
@@ -1,16 +0,0 @@
1
- /**
2
- * Infer display title from PascalCase component name.
3
- *
4
- * TeamRoster → "Team Roster"
5
- * CTA → "CTA"
6
- * FAQSection → "FAQ Section"
7
- * Hero → "Hero"
8
- *
9
- * @param {string} name - PascalCase component name
10
- * @returns {string} Human-readable title
11
- */
12
- export function inferTitle(name) {
13
- return name
14
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
15
- .replace(/([a-z])([A-Z])/g, '$1 $2')
16
- }