@uniweb/build 0.66.0 → 0.67.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 +8 -8
- package/src/schema.js +64 -30
- package/src/site/build-site-data.js +2 -2
- package/src/site/content-collector.js +20 -61
- package/src/site/plugin.js +2 -2
- package/src/site/query-processor.js +23 -3
- package/src/site/records-config.js +126 -45
- package/src/site/schemaless-data.js +4 -3
- package/src/uwx/folder.js +12 -4
- package/src/uwx/records-project.js +20 -5
- package/src/uwx/records.js +6 -1
- package/src/uwx/site-project.js +3 -2
- package/src/uwx/site.js +11 -2
- package/src/utils/infer-title.js +0 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
61
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/projections": "^0.7.
|
|
61
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
62
|
+
"@uniweb/projections": "^0.7.7",
|
|
63
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
64
64
|
"@uniweb/theming": "^0.1.15",
|
|
65
|
-
"@uniweb/
|
|
65
|
+
"@uniweb/schemas": "^0.8.1"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.9"
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.32.1",
|
|
78
|
+
"@uniweb/runtime": "^0.27.9"
|
|
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 {
|
|
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(
|
|
551
|
-
return {
|
|
550
|
+
function createImplicitMeta() {
|
|
551
|
+
return {}
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
/**
|
|
555
|
-
* Build a component entry
|
|
555
|
+
* Build a component entry: the `meta.js` default export, plus the build's `name`
|
|
556
|
+
* and `path`.
|
|
556
557
|
*
|
|
557
|
-
* ⭐ `
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
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
|
-
* ⛔
|
|
564
|
-
*
|
|
565
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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` (+
|
|
64
|
-
*
|
|
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 `{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 —
|
|
@@ -1064,14 +1011,27 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
1064
1011
|
|
|
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
|
+
// ⭐ Nor is `grid`: a section key framework reserves for the layout of the section's
|
|
1015
|
+
// child sections — `3` or `'40/60'` [Diego, 2026-09-27] — laid out by kit's
|
|
1016
|
+
// `ChildGrid` from `block.grid`. A component offers layouts in `meta.js`
|
|
1017
|
+
// `children.grid`; it never receives the choice as a param.
|
|
1018
|
+
const { type, preset, input, props, fetch, query, data, grid, id: frontmatterId, ...params } = frontMatter
|
|
1019
|
+
if (grid !== undefined && !parseGrid(grid)) {
|
|
1020
|
+
console.warn(
|
|
1021
|
+
`[content-collector] ${relative(siteRoot, filePath)}: grid: ${JSON.stringify(grid)} is not a layout — ` +
|
|
1022
|
+
`write a column count (grid: 3) or relative widths (grid: '40/60'). The component's own layout applies.`
|
|
1023
|
+
)
|
|
1024
|
+
}
|
|
1068
1025
|
|
|
1069
|
-
// Convert markdown to ProseMirror
|
|
1026
|
+
// Convert markdown to ProseMirror. ⭐ Insets stay as the author wrote them —
|
|
1027
|
+
// `inset_ref`, `inset_block` — and `@uniweb/core` lifts both when it builds the
|
|
1028
|
+
// section's Block. ⛔ Until 2026-09-27 this extracted each leaf `inset_ref` into a
|
|
1029
|
+
// section-level `insets[]` and left a placeholder, so any document that did not
|
|
1030
|
+
// come through here (a free-form translation, a record body) kept raw references
|
|
1031
|
+
// nothing resolved, and the same section's content differed between a build and a
|
|
1032
|
+
// document stored as written.
|
|
1070
1033
|
const proseMirrorContent = markdownToProseMirror(markdown)
|
|
1071
1034
|
|
|
1072
|
-
// Extract @ component references → insets (mutates doc)
|
|
1073
|
-
const insets = extractInsets(proseMirrorContent)
|
|
1074
|
-
|
|
1075
1035
|
// `query: team` → `fetch: { query: team }`; a list, one config per name, each under
|
|
1076
1036
|
// its own `as` — the one helper every level uses. Unrelated to a section type's
|
|
1077
1037
|
// `meta.js` `data:`, which declares the `content.data` keys a component receives and
|
|
@@ -1092,7 +1052,7 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
1092
1052
|
params: { ...params, ...props },
|
|
1093
1053
|
content: proseMirrorContent,
|
|
1094
1054
|
fetch: parseFetchConfig(resolvedFetch, relative(siteRoot, filePath), { level: 'section' }),
|
|
1095
|
-
...(
|
|
1055
|
+
...(grid !== undefined ? { grid } : {}),
|
|
1096
1056
|
subsections: []
|
|
1097
1057
|
}
|
|
1098
1058
|
|
|
@@ -2994,7 +2954,6 @@ export {
|
|
|
2994
2954
|
parseWildcardArray,
|
|
2995
2955
|
applyWildcardOrder,
|
|
2996
2956
|
getDirectChildName,
|
|
2997
|
-
extractInsets,
|
|
2998
2957
|
readYamlFile,
|
|
2999
2958
|
readFolderConfig,
|
|
3000
2959
|
isMarkdownFile,
|
package/src/site/plugin.js
CHANGED
|
@@ -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
|
-
//
|
|
1339
|
-
//
|
|
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]
|
|
@@ -932,6 +932,16 @@ function pickBrief(record, keys) {
|
|
|
932
932
|
return out
|
|
933
933
|
}
|
|
934
934
|
|
|
935
|
+
// What a record's folder entry says about it, as the keys a record is answered with — `$tags`
|
|
936
|
+
// (in the order written) and `$label` — or null when it says nothing.
|
|
937
|
+
function entryKeys(placed) {
|
|
938
|
+
if (!placed) return null
|
|
939
|
+
const out = {}
|
|
940
|
+
if (Array.isArray(placed.tags) && placed.tags.length) out.$tags = [...placed.tags]
|
|
941
|
+
if (typeof placed.label === 'string' && placed.label) out.$label = placed.label
|
|
942
|
+
return Object.keys(out).length ? out : null
|
|
943
|
+
}
|
|
944
|
+
|
|
935
945
|
// A query's records in folder-tree order (`folderTreeOrder`), before anything reads them — so
|
|
936
946
|
// `where`, `sort` (stable) and `limit` start from the order a records service answers in.
|
|
937
947
|
function inFolderOrder(pooled, rank) {
|
|
@@ -985,10 +995,20 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
|
|
|
985
995
|
//
|
|
986
996
|
// ⚠️ It stays a SCALAR. `@uniweb/core`'s `withinScope` matches strings only, so
|
|
987
997
|
// an array would match nothing — one placement per record is the ruling.
|
|
998
|
+
//
|
|
999
|
+
// ⭐ AND WHAT THE FOLDER SAYS ABOUT THE RECORD — its entry's `tags` and `label` — rides
|
|
1000
|
+
// beside it as `$tags` and `$label`, in its list and in its own file, as a records service
|
|
1001
|
+
// answers them (ruled 2026-09-27 [Diego]). Absent when the entry says none.
|
|
988
1002
|
items = items.map((result, i) => {
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
1003
|
+
const placed = pooled[i] ? config.placements?.get(pooled[i].id) : null
|
|
1004
|
+
const said = entryKeys(placed)
|
|
1005
|
+
const at = (item) => {
|
|
1006
|
+
if (!item) return item
|
|
1007
|
+
const out = { ...item, ...said, [BRANCH_KEY]: placed?.path ?? '' }
|
|
1008
|
+
if (said && out[WHOLE_RECORD]) out[WHOLE_RECORD] = { ...out[WHOLE_RECORD], ...said }
|
|
1009
|
+
return out
|
|
1010
|
+
}
|
|
1011
|
+
return Array.isArray(result) ? result.map(at) : at(result)
|
|
992
1012
|
})
|
|
993
1013
|
|
|
994
1014
|
// Flatten one level: a .bib file contributes its entries individually. Every other
|
|
@@ -164,9 +164,25 @@ export async function readRecordsConfig(siteRoot, { dir } = {}) {
|
|
|
164
164
|
* @param {object} [opts]
|
|
165
165
|
* @param {string} [opts.dir] - the records directory as written, for messages
|
|
166
166
|
* @returns {{ nodes: Array, placements: Map, errors: string[], warnings: string[] }}
|
|
167
|
-
* `nodes` is the folder tree — the declared
|
|
168
|
-
* record no
|
|
169
|
-
* `{ entity, path, slug }`, `path` being `''` at the top of the folder.
|
|
167
|
+
* `nodes` is the folder tree — the declared entries in file order, then every
|
|
168
|
+
* record no entry names; `placements` maps EVERY record's id to its
|
|
169
|
+
* `{ entity, path, slug, tags?, label? }`, `path` being `''` at the top of the folder.
|
|
170
|
+
*
|
|
171
|
+
* ⭐ WHAT THE FOLDER SAYS ABOUT A RECORD — its entry's `tags` and `label` (ruled
|
|
172
|
+
* 2026-09-27 [Diego]). A record's entry is a path, or `{ path, tags?, label? }`:
|
|
173
|
+
*
|
|
174
|
+
* - folder: team
|
|
175
|
+
* records:
|
|
176
|
+
* - team/grace.md
|
|
177
|
+
* - path: team/ada.md
|
|
178
|
+
* tags: [staff, featured]
|
|
179
|
+
* label: Ada Lovelace
|
|
180
|
+
*
|
|
181
|
+
* They are the ENTRY's, not the record's — a backend keeps them on the folder entry and
|
|
182
|
+
* answers them beside the record as `$tags` and `$label`, and so does the static lane.
|
|
183
|
+
* Tags keep the order written. At the top level an entry is allowed only when it says
|
|
184
|
+
* one of them: a bare path there would claim to place a record the directory already
|
|
185
|
+
* placed.
|
|
170
186
|
*/
|
|
171
187
|
export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
172
188
|
// The file as the author sees it, for every message below.
|
|
@@ -187,7 +203,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
187
203
|
byRelPath.set(e.poolPath, e)
|
|
188
204
|
}
|
|
189
205
|
|
|
190
|
-
const place = (entity, pathSegs, where) => {
|
|
206
|
+
const place = (entity, pathSegs, where, said = null) => {
|
|
191
207
|
const key = entity.id
|
|
192
208
|
const prior = claimedBy.get(key)
|
|
193
209
|
if (prior) {
|
|
@@ -200,60 +216,94 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
200
216
|
claimedBy.set(key, where)
|
|
201
217
|
const slug = slugForEntity(entity)
|
|
202
218
|
const path = pathSegs.join('/')
|
|
203
|
-
placements.set(key, { entity, path, slug })
|
|
204
|
-
return { kind: 'ref', name: slug, $entityId: key }
|
|
219
|
+
placements.set(key, { entity, path, slug, ...said })
|
|
220
|
+
return { kind: 'ref', name: slug, $entityId: key, ...said }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The records one path names — a file, or a pattern's matches — placed where the entry sits.
|
|
224
|
+
const placePath = (raw, pathSegs, where, said = null) => {
|
|
225
|
+
const pattern = typeof raw === 'string' ? raw.trim() : ''
|
|
226
|
+
if (!pattern) {
|
|
227
|
+
errors.push(`${file}: ${where} ${said ? 'has an empty `path:`' : 'is an empty string'}.`)
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
if (!isPattern(pattern)) {
|
|
231
|
+
const hit = byRelPath.get(pattern)
|
|
232
|
+
if (!hit) {
|
|
233
|
+
errors.push(
|
|
234
|
+
`${file}: ${where} names "${pattern}", which is not in ${dir}/. ` +
|
|
235
|
+
`A path is relative to ${dir}/, extension included.`
|
|
236
|
+
)
|
|
237
|
+
return []
|
|
238
|
+
}
|
|
239
|
+
const leaf = place(hit, pathSegs, where, said)
|
|
240
|
+
return leaf ? [leaf] : []
|
|
241
|
+
}
|
|
242
|
+
// ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
|
|
243
|
+
// empty-branch defect respelled, and it produced a real, reachable, empty
|
|
244
|
+
// path with no warning.
|
|
245
|
+
const matches = [...byRelPath.entries()]
|
|
246
|
+
.filter(([rel]) => matchEntityPattern(pattern, rel))
|
|
247
|
+
.map(([, e]) => e)
|
|
248
|
+
if (matches.length === 0) {
|
|
249
|
+
errors.push(
|
|
250
|
+
`${file}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
|
|
251
|
+
`Check the schema folder name and the extension.`
|
|
252
|
+
)
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
// Matches sort alphanumerically by filename, numeric-aware — so `1-`, `2-`,
|
|
256
|
+
// `10-` order as written rather than as strings, and `2025-…` precedes
|
|
257
|
+
// `2026-…`. Ordering only: the number never leaves the name.
|
|
258
|
+
matches.sort((a, b) => compareByNumericPrefix(a.slug, b.slug))
|
|
259
|
+
return matches.map((e) => place(e, pathSegs, where, said)).filter(Boolean)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// An entry's `tags` and `label` — text, as YAML may type a tag or a label a number
|
|
263
|
+
// (`2024`). Tags keep the order written; an empty one is dropped. Null when one is
|
|
264
|
+
// not text (said).
|
|
265
|
+
const saidOf = (entry, where) => {
|
|
266
|
+
const said = {}
|
|
267
|
+
if (entry.tags !== undefined && entry.tags !== null) {
|
|
268
|
+
const tags = []
|
|
269
|
+
for (const tag of Array.isArray(entry.tags) ? entry.tags : [entry.tags]) {
|
|
270
|
+
if (typeof tag !== 'string' && typeof tag !== 'number') {
|
|
271
|
+
errors.push(`${file}: ${where} has a tag that is not text: ${JSON.stringify(tag)}. Write each tag as a word.`)
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
const text = String(tag).trim()
|
|
275
|
+
if (text) tags.push(text)
|
|
276
|
+
}
|
|
277
|
+
if (tags.length) said.tags = tags
|
|
278
|
+
}
|
|
279
|
+
if (entry.label !== undefined && entry.label !== null) {
|
|
280
|
+
if (typeof entry.label !== 'string' && typeof entry.label !== 'number') {
|
|
281
|
+
errors.push(`${file}: ${where} has a \`label:\` that is not text. A label is the entry's display text.`)
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
const label = String(entry.label).trim()
|
|
285
|
+
if (label) said.label = label
|
|
286
|
+
}
|
|
287
|
+
return said
|
|
205
288
|
}
|
|
206
289
|
|
|
207
290
|
const resolveEntry = (entry, pathSegs, index, trail) => {
|
|
208
291
|
const where = `entry ${trail}[${index}]`
|
|
209
292
|
|
|
210
293
|
if (typeof entry === 'string') {
|
|
211
|
-
const pattern = entry.trim()
|
|
212
294
|
// ⛔ A PATH AT THE TOP LEVEL SELECTED RECORDS until 2026-09-21 — it was how a
|
|
213
295
|
// file became one. Every file in the directory is a record now, so the line
|
|
214
296
|
// would claim to choose while choosing nothing: refused, with the reason.
|
|
215
297
|
if (pathSegs.length === 0) {
|
|
216
298
|
errors.push(
|
|
217
|
-
`${file}: ${where} ("${
|
|
218
|
-
`${dir}/ is a record already — ${file} only sorts records into folders
|
|
299
|
+
`${file}: ${where} ("${entry.trim()}") lists records at the top level. Every file in ` +
|
|
300
|
+
`${dir}/ is a record already — ${file} only sorts records into folders, and says ` +
|
|
301
|
+
`what a folder knows about a record (\`tags:\`, \`label:\`). ` +
|
|
219
302
|
`Remove the entry, or put it under a \`folder:\`.`
|
|
220
303
|
)
|
|
221
304
|
return []
|
|
222
305
|
}
|
|
223
|
-
|
|
224
|
-
errors.push(`${file}: ${where} is an empty string.`)
|
|
225
|
-
return []
|
|
226
|
-
}
|
|
227
|
-
if (!isPattern(pattern)) {
|
|
228
|
-
const hit = byRelPath.get(pattern)
|
|
229
|
-
if (!hit) {
|
|
230
|
-
errors.push(
|
|
231
|
-
`${file}: ${where} names "${pattern}", which is not in ${dir}/. ` +
|
|
232
|
-
`A path is relative to ${dir}/, extension included.`
|
|
233
|
-
)
|
|
234
|
-
return []
|
|
235
|
-
}
|
|
236
|
-
const leaf = place(hit, pathSegs, where)
|
|
237
|
-
return leaf ? [leaf] : []
|
|
238
|
-
}
|
|
239
|
-
// ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
|
|
240
|
-
// empty-branch defect respelled, and it produced a real, reachable, empty
|
|
241
|
-
// path with no warning.
|
|
242
|
-
const matches = [...byRelPath.entries()]
|
|
243
|
-
.filter(([rel]) => matchEntityPattern(pattern, rel))
|
|
244
|
-
.map(([, e]) => e)
|
|
245
|
-
if (matches.length === 0) {
|
|
246
|
-
errors.push(
|
|
247
|
-
`${file}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
|
|
248
|
-
`Check the schema folder name and the extension.`
|
|
249
|
-
)
|
|
250
|
-
return []
|
|
251
|
-
}
|
|
252
|
-
// Matches sort alphanumerically by filename, numeric-aware — so `1-`, `2-`,
|
|
253
|
-
// `10-` order as written rather than as strings, and `2025-…` precedes
|
|
254
|
-
// `2026-…`. Ordering only: the number never leaves the name.
|
|
255
|
-
matches.sort((a, b) => compareByNumericPrefix(a.slug, b.slug))
|
|
256
|
-
return matches.map((e) => place(e, pathSegs, where)).filter(Boolean)
|
|
306
|
+
return placePath(entry, pathSegs, where)
|
|
257
307
|
}
|
|
258
308
|
|
|
259
309
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
@@ -263,6 +313,30 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
263
313
|
return []
|
|
264
314
|
}
|
|
265
315
|
|
|
316
|
+
// ⭐ A RECORD'S ENTRY WITH WHAT THE FOLDER SAYS ABOUT IT — `{ path, tags?, label? }`.
|
|
317
|
+
if (entry.path !== undefined) {
|
|
318
|
+
const extra = Object.keys(entry).filter((k) => !['path', 'tags', 'label'].includes(k))
|
|
319
|
+
if (extra.length) {
|
|
320
|
+
errors.push(
|
|
321
|
+
`${file}: ${where} is a record's entry (\`path:\`), which takes \`tags:\` and \`label:\` — ` +
|
|
322
|
+
`not ${extra.map((k) => `\`${k}:\``).join(', ')}.` +
|
|
323
|
+
(extra.includes('folder') || extra.includes('records') ? ' A folder is its own entry, `folder:`.' : '')
|
|
324
|
+
)
|
|
325
|
+
return []
|
|
326
|
+
}
|
|
327
|
+
const said = saidOf(entry, where)
|
|
328
|
+
if (!said) return []
|
|
329
|
+
if (pathSegs.length === 0 && !said.tags && !said.label) {
|
|
330
|
+
errors.push(
|
|
331
|
+
`${file}: ${where} ("${String(entry.path).trim()}") lists a record at the top level and says ` +
|
|
332
|
+
`nothing about it. Every file in ${dir}/ sits at the top already — give it \`tags:\` or a ` +
|
|
333
|
+
`\`label:\`, or remove the entry.`
|
|
334
|
+
)
|
|
335
|
+
return []
|
|
336
|
+
}
|
|
337
|
+
return placePath(entry.path, pathSegs, where, said)
|
|
338
|
+
}
|
|
339
|
+
|
|
266
340
|
if (entry.folder !== undefined) {
|
|
267
341
|
// ⚠️ COERCED, because YAML types a bare `2024` as a NUMBER — and a folder
|
|
268
342
|
// named for a year is the single most likely one anybody writes. Both the
|
|
@@ -298,6 +372,13 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
298
372
|
folderAt.set(at, where)
|
|
299
373
|
const branch = { kind: 'branch', name: segment }
|
|
300
374
|
if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
|
|
375
|
+
// A folder takes `tags:` as a record's entry does — kept, sent and pulled back, though
|
|
376
|
+
// no question answers a folder.
|
|
377
|
+
if (entry.tags !== undefined && entry.tags !== null) {
|
|
378
|
+
const said = saidOf({ tags: entry.tags }, where)
|
|
379
|
+
if (!said) return []
|
|
380
|
+
if (said.tags) branch.tags = said.tags
|
|
381
|
+
}
|
|
301
382
|
const kids = Array.isArray(entry.records) ? entry.records : []
|
|
302
383
|
if (kids.length === 0) {
|
|
303
384
|
warnings.push(
|
|
@@ -328,8 +409,8 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
328
409
|
errors.push(
|
|
329
410
|
`${file}: ${where} has no recognized kind. ` +
|
|
330
411
|
(pathSegs.length
|
|
331
|
-
? `Inside a folder, a path names records
|
|
332
|
-
: `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}
|
|
412
|
+
? `Inside a folder, a path names records — or \`path:\` with its \`tags:\` and \`label:\`; anything else says \`folder:\`.`
|
|
413
|
+
: `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}/ — or a record's \`path:\` with its \`tags:\` or \`label:\`.`)
|
|
333
414
|
)
|
|
334
415
|
return []
|
|
335
416
|
}
|
|
@@ -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
|
|
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
|
-
*
|
|
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
|
package/src/uwx/folder.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// - `contents` is the self-nesting tree (an array), nesting via `$children` — the
|
|
8
8
|
// same mechanism site-content pages/sections use. Each node holds REFERENCES,
|
|
9
9
|
// never content:
|
|
10
|
-
// - a LEAF references one record entity: `{ kind: 'ref', name, entry }` —
|
|
10
|
+
// - a LEAF references one record entity: `{ kind: 'ref', name, entry, label?, tags? }` —
|
|
11
11
|
// `entry: { schema, entity: <uuid> }` once the record was minted (back-filled
|
|
12
12
|
// into its file), or `entry: { $ref: "<$id>" }` while brand-new (resolved
|
|
13
|
-
// within this payload)
|
|
14
|
-
//
|
|
13
|
+
// within this payload); `label` (a localized map) and `tags` are what
|
|
14
|
+
// `records/folder.yml` says about the record, since 2026-09-27.
|
|
15
|
+
// - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, tags?, $children }`.
|
|
15
16
|
//
|
|
16
17
|
// ⭐ A LEAF'S `name` IS ITS RECORD'S HANDLE — the slug, the records service's `$name` —
|
|
17
18
|
// and a branch's `name` is the segment a query's `scope:` names. `label` is a branch's
|
|
@@ -99,6 +100,7 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
99
100
|
// map, like every localized scalar this producer sends — keyed by the
|
|
100
101
|
// site's source locale.
|
|
101
102
|
if (node.label !== undefined) branch.label = { [sourceLocale]: String(node.label) }
|
|
103
|
+
if (Array.isArray(node.tags) && node.tags.length) branch.tags = [...node.tags]
|
|
102
104
|
branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale)
|
|
103
105
|
out.push(branch)
|
|
104
106
|
continue
|
|
@@ -108,7 +110,13 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
108
110
|
missing.push(node.$entityId)
|
|
109
111
|
continue
|
|
110
112
|
}
|
|
111
|
-
|
|
113
|
+
const leaf = refLeaf(entity)
|
|
114
|
+
// ⭐ What the folder says about the record (`records/folder.yml`, ruled 2026-09-27
|
|
115
|
+
// [Diego]): its display text, localized as a branch's is, and its tags, in the order
|
|
116
|
+
// written. A records service answers them beside the record as `$label` and `$tags`.
|
|
117
|
+
if (node.label !== undefined) leaf.label = { [sourceLocale]: String(node.label) }
|
|
118
|
+
if (Array.isArray(node.tags) && node.tags.length) leaf.tags = [...node.tags]
|
|
119
|
+
out.push(leaf)
|
|
112
120
|
}
|
|
113
121
|
return out
|
|
114
122
|
}
|
|
@@ -493,7 +493,9 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
493
493
|
* there — so `folder.yml` carries the branches and the records placed in them, and
|
|
494
494
|
* a folder with no branches is a site with no `folder.yml`: a local one is REMOVED
|
|
495
495
|
* (it could only describe sub-folders the backend's folder no longer has). No state
|
|
496
|
-
* of the file removes a record, so this cannot empty anything.
|
|
496
|
+
* of the file removes a record, so this cannot empty anything. ⭐ The one exception,
|
|
497
|
+
* since 2026-09-27: a record whose entry has `tags` or a `label` is written wherever it
|
|
498
|
+
* sits, as `{ path, tags?, label? }`, since only `folder.yml` can say them.
|
|
497
499
|
*
|
|
498
500
|
* ⛔ A PULL THAT CARRIED NO FOLDER, or one with a placed record that was not written
|
|
499
501
|
* locally, leaves the file alone: it has nothing true to write.
|
|
@@ -524,22 +526,34 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
524
526
|
// does not caption its rows. On the wire the label is a localized map;
|
|
525
527
|
// folder.yml carries the source-locale string (a bare string passes).
|
|
526
528
|
if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
|
|
529
|
+
if (Array.isArray(node.tags) && node.tags.length) entry.tags = node.tags.map(String)
|
|
527
530
|
entry.records = walk(node.$children, true)
|
|
528
531
|
out.push(entry)
|
|
529
532
|
continue
|
|
530
533
|
}
|
|
531
534
|
const uuid = node.entry?.entity ?? node.entry
|
|
532
535
|
const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
+
// ⭐ What the folder says about the record — its entry's `tags` and `label` — comes
|
|
537
|
+
// back as the entry `{ path, tags?, label? }` (ruled 2026-09-27 [Diego]).
|
|
538
|
+
const said = {}
|
|
539
|
+
if (Array.isArray(node.tags) && node.tags.length) said.tags = node.tags.map(String)
|
|
540
|
+
if (node.label !== undefined && node.label !== null) {
|
|
541
|
+
const label = unwrapLocalized(node.label, sourceLocale)
|
|
542
|
+
if (typeof label === 'string' && label) said.label = label
|
|
543
|
+
}
|
|
544
|
+
const says = Object.keys(said).length > 0
|
|
545
|
+
// A record at the top of the folder needs no line unless the folder says something
|
|
546
|
+
// about it — but one that did not land is still said: the next push sends the folder
|
|
547
|
+
// the directory holds, which would not have it.
|
|
536
548
|
if (!inBranch) {
|
|
537
549
|
if (!rel) {
|
|
538
550
|
warnings.push(
|
|
539
551
|
`the folder holds a record ("${node.name ?? '?'}") that was not written locally — ` +
|
|
540
552
|
`a push from here would remove it from the folder.`
|
|
541
553
|
)
|
|
554
|
+
continue
|
|
542
555
|
}
|
|
556
|
+
if (says && !out.some((e) => e?.path === rel)) out.push({ path: rel, ...said })
|
|
543
557
|
continue
|
|
544
558
|
}
|
|
545
559
|
if (!rel) {
|
|
@@ -554,7 +568,8 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
554
568
|
continue
|
|
555
569
|
}
|
|
556
570
|
// A list file holds several records and is one path — listed once.
|
|
557
|
-
if (
|
|
571
|
+
if (out.some((e) => e === rel || e?.path === rel)) continue
|
|
572
|
+
out.push(says ? { path: rel, ...said } : rel)
|
|
558
573
|
}
|
|
559
574
|
return out
|
|
560
575
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -729,8 +729,13 @@ function placeProducedRecords(nodes, producedBy) {
|
|
|
729
729
|
out.push({ ...node, $children: placeProducedRecords(node.$children, producedBy) })
|
|
730
730
|
continue
|
|
731
731
|
}
|
|
732
|
+
// What the folder says about the record — its entry's `tags` and `label` — goes with
|
|
733
|
+
// every record the file produced.
|
|
734
|
+
const said = {}
|
|
735
|
+
if (node?.tags) said.tags = node.tags
|
|
736
|
+
if (node?.label !== undefined) said.label = node.label
|
|
732
737
|
for (const r of producedBy.get(node?.$entityId) || []) {
|
|
733
|
-
out.push({ kind: 'ref', name: r.slug, $entityId: r.id })
|
|
738
|
+
out.push({ kind: 'ref', name: r.slug, $entityId: r.id, ...said })
|
|
734
739
|
}
|
|
735
740
|
}
|
|
736
741
|
return out
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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` (
|
|
432
|
-
*
|
|
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,11 +312,20 @@ 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)
|
|
319
|
+
// ⛔ `grid` — the section's layout for its children — is NOT SENT YET: it is a new
|
|
320
|
+
// field, and the store refuses a whole push carrying a key it has not declared
|
|
321
|
+
// (kb/framework/build/uwx-format.md § A page section's fields). It goes on the wire
|
|
322
|
+
// once backend declares it; until then a static build renders it and the file keeps it.
|
|
323
|
+
if (section.grid !== undefined) {
|
|
324
|
+
console.warn(
|
|
325
|
+
`[uwx] section ${section.stableId ?? section.id}: grid: ${JSON.stringify(section.grid)} is not sent — ` +
|
|
326
|
+
`the backend does not store a section's grid yet. It renders on a static build; the file keeps it.`
|
|
327
|
+
)
|
|
328
|
+
}
|
|
320
329
|
return data
|
|
321
330
|
}
|
|
322
331
|
|
package/src/utils/infer-title.js
DELETED
|
@@ -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
|
-
}
|