@uniweb/build 0.14.11 → 0.14.12
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/uwx/collections.js +74 -40
- package/src/uwx/folder.js +30 -19
- package/src/uwx/site.js +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.12",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.33.2",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/theming": "0.1.3",
|
|
63
|
+
"@uniweb/content-writer": "0.2.5"
|
|
64
64
|
},
|
|
65
65
|
"optionalDependencies": {
|
|
66
|
-
"@uniweb/schemas": "0.2.3",
|
|
67
66
|
"@uniweb/content-reader": "1.1.12",
|
|
68
|
-
"@uniweb/
|
|
67
|
+
"@uniweb/schemas": "0.2.3",
|
|
68
|
+
"@uniweb/runtime": "0.8.17"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
package/src/uwx/collections.js
CHANGED
|
@@ -96,7 +96,12 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
96
96
|
// path, flushed to locales/collections/{locale}.json by the caller.
|
|
97
97
|
const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
|
|
98
98
|
if (!field.localized) return doc
|
|
99
|
-
|
|
99
|
+
const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
|
|
100
|
+
// localizeContentDoc returns a BARE doc when there are no target locales. A
|
|
101
|
+
// localized field MUST ride as a `{ lang: value }` map on the wire — the
|
|
102
|
+
// schema-driven projector drops a localized field whose value isn't a map — so
|
|
103
|
+
// wrap the source doc, consistent with localizeScalar (which always wraps).
|
|
104
|
+
return isLocalizedContent(localized) ? localized : { [sourceLocale]: localized }
|
|
100
105
|
}
|
|
101
106
|
if (field.localized) {
|
|
102
107
|
// A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
|
|
@@ -143,33 +148,52 @@ export function collectionRecordsToEntities({
|
|
|
143
148
|
if (!declaration || !declaration.name) {
|
|
144
149
|
throw new Error('uwx/collections: a declaration with a name is required')
|
|
145
150
|
}
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
151
|
+
// A record (one source file) maps to the Model's SINGLE sections in declared
|
|
152
|
+
// order — the brief (the card) plus any sibling single sections, e.g. a body
|
|
153
|
+
// section like `article_body`. Multi-section Models are the norm for `@std/*`
|
|
154
|
+
// types; the markdown body lands in the designated content field WHEREVER it is
|
|
155
|
+
// declared (the brief, or a non-brief body section). `multi` sections (repeating
|
|
156
|
+
// items) can't be expressed by one flat record and are skipped. The brief is the
|
|
157
|
+
// section marked `brief: true` (the sections-tree has no schema-level back-ref).
|
|
158
|
+
const sectionEntries = Object.entries(declaration.sections || {})
|
|
159
|
+
const briefEntry = sectionEntries.find(([, s]) => s && s.brief === true)
|
|
149
160
|
const briefName = briefEntry?.[0]
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
if (!briefName) {
|
|
162
|
+
throw new Error(`uwx/collections: Model ${declaration.name} has no brief section`)
|
|
163
|
+
}
|
|
164
|
+
// The single sections a flat record can populate (the brief + sibling singles),
|
|
165
|
+
// and a global field→section map across them — for distributing frontmatter and
|
|
166
|
+
// flagging unknown keys. Field names are unique across a Model's sections (the
|
|
167
|
+
// declaration's own convention); a collision keeps the first occurrence.
|
|
168
|
+
const recordSections = sectionEntries.filter(([, s]) => s && s.multiple !== true)
|
|
169
|
+
const fieldByKey = new Map()
|
|
170
|
+
for (const [, sec] of recordSections) {
|
|
171
|
+
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
172
|
+
if (!fieldByKey.has(key)) fieldByKey.set(key, field)
|
|
173
|
+
}
|
|
156
174
|
}
|
|
157
|
-
const briefFields = brief.fields || {}
|
|
158
|
-
const fieldByKey = new Map(Object.entries(briefFields))
|
|
159
175
|
|
|
160
|
-
// The markdown body of a `.md`
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
// The markdown body of a `.md` record is the value of the Model's CONTENT body
|
|
177
|
+
// field — a markup `text` field (raw source string) or a `format: prosemirror`
|
|
178
|
+
// json field (docs/reference/entity-content.md) — wherever it is declared (the
|
|
179
|
+
// brief, or a non-brief body section like `article_body.content`). encodeFieldValue
|
|
180
|
+
// does the md→ProseMirror conversion per field kind. One content field is the body
|
|
181
|
+
// target; zero means a `.md` body has nowhere to go (warn per record).
|
|
182
|
+
const contentMatches = []
|
|
183
|
+
for (const [secName, sec] of recordSections) {
|
|
184
|
+
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
185
|
+
if (isContentBodyField(field)) contentMatches.push({ secName, key })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const bodyTarget = contentMatches[0] || null
|
|
166
189
|
|
|
167
190
|
const entities = []
|
|
168
191
|
const warnings = []
|
|
169
|
-
if (
|
|
192
|
+
if (contentMatches.length > 1) {
|
|
170
193
|
warnings.push(
|
|
171
|
-
`${collectionName}: ${declaration.name}
|
|
172
|
-
`(markdown / html / prosemirror) field — the markdown body maps to
|
|
194
|
+
`${collectionName}: ${declaration.name} has more than one content ` +
|
|
195
|
+
`(markdown / html / prosemirror) field — the markdown body maps to ` +
|
|
196
|
+
`"${bodyTarget.secName}.${bodyTarget.key}"`
|
|
173
197
|
)
|
|
174
198
|
}
|
|
175
199
|
for (const record of records || []) {
|
|
@@ -185,43 +209,53 @@ export function collectionRecordsToEntities({
|
|
|
185
209
|
const uuid = record.$uuid || null
|
|
186
210
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
187
211
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
// Per-section data in schema-declared field order (the wire's canonical order).
|
|
213
|
+
// Frontmatter keys land in their declaring section; the markdown body fills the
|
|
214
|
+
// designated content field (in whatever section declares it) unless frontmatter
|
|
215
|
+
// already set it explicitly. An absent field is simply omitted — an incomplete
|
|
216
|
+
// entity is a valid stored state; the foundation copes at render time.
|
|
217
|
+
const sectionData = {}
|
|
218
|
+
for (const [secName, sec] of recordSections) {
|
|
219
|
+
const data = {}
|
|
220
|
+
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
221
|
+
let value = record[key]
|
|
222
|
+
if (value === undefined && bodyTarget && secName === bodyTarget.secName && key === bodyTarget.key && hasBody) {
|
|
223
|
+
value = record.$body
|
|
224
|
+
}
|
|
225
|
+
if (value === undefined) continue
|
|
226
|
+
const encoded = encodeFieldValue(value, field, sourceLocale, translations)
|
|
227
|
+
if (encoded !== undefined) data[key] = encoded
|
|
228
|
+
}
|
|
229
|
+
if (Object.keys(data).length) sectionData[secName] = data
|
|
199
230
|
}
|
|
200
|
-
// Warn for author keys
|
|
231
|
+
// Warn for author keys not on ANY record section. A real unknown key means the
|
|
201
232
|
// frontmatter doesn't match the collection's data schema — that SHOULD warn
|
|
202
233
|
// (only identity/transport keys in SKIP_KEYS are silent).
|
|
203
234
|
for (const key of Object.keys(record)) {
|
|
204
235
|
if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
|
|
205
236
|
warnings.push(
|
|
206
237
|
`${collectionName}/${slug}: field "${key}" is not on ` +
|
|
207
|
-
`${declaration.name}
|
|
238
|
+
`${declaration.name} — not synced`
|
|
208
239
|
)
|
|
209
240
|
}
|
|
210
|
-
if (hasBody && !
|
|
241
|
+
if (hasBody && !bodyTarget) {
|
|
211
242
|
warnings.push(
|
|
212
243
|
`${collectionName}/${slug}: markdown body present but ` +
|
|
213
|
-
`${declaration.name}
|
|
244
|
+
`${declaration.name} has no content body field — body not synced`
|
|
214
245
|
)
|
|
215
246
|
}
|
|
216
247
|
|
|
217
|
-
// The `$`-document
|
|
218
|
-
//
|
|
219
|
-
// binds owner + unit on its side.
|
|
248
|
+
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, then each
|
|
249
|
+
// populated section in declared order (the brief always present as the card).
|
|
250
|
+
// `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on its side.
|
|
220
251
|
const document = {}
|
|
221
252
|
if (uuid) document.$uuid = uuid
|
|
222
253
|
document.$id = id
|
|
223
254
|
document.$model = declaration.name
|
|
224
|
-
document[briefName] =
|
|
255
|
+
document[briefName] = sectionData[briefName] || {}
|
|
256
|
+
for (const [secName] of recordSections) {
|
|
257
|
+
if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
|
|
258
|
+
}
|
|
225
259
|
|
|
226
260
|
entities.push({
|
|
227
261
|
id,
|
package/src/uwx/folder.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A site sync carries the site-content entity, the collection-record entities, and
|
|
4
4
|
// — when the site has collections — ONE `@uniweb/folder` entity describing how those
|
|
5
|
-
// records are organized.
|
|
6
|
-
//
|
|
7
|
-
// -
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
5
|
+
// records are organized. `@uniweb/folder` is a normal section-keyed entity (the
|
|
6
|
+
// "structured content all the way down" invariant): its document is `{ info?, contents }`.
|
|
7
|
+
// - `contents` is the self-nesting tree (an array), nesting via `$children` — the
|
|
8
|
+
// same mechanism site-content pages/sections use. Each node holds REFERENCES,
|
|
9
|
+
// never content:
|
|
10
|
+
// - a LEAF references one record entity: `{ kind: 'ref', path_segment, ... }`
|
|
11
|
+
// with `entry: <uuid>` once the record was minted (back-filled into its file),
|
|
12
|
+
// or `$ref: "<collection>/<slug>"` while brand-new (resolved within this payload).
|
|
13
|
+
// - a BRANCH is a sub-folder: `{ kind: 'branch', path_segment, name?, $children }`.
|
|
11
14
|
//
|
|
12
15
|
// Organization comes from `collections.yml::folders` (a VIRTUAL tree, decoupled from
|
|
13
16
|
// the on-disk layout) when present; otherwise the default is one branch per
|
|
@@ -20,10 +23,18 @@
|
|
|
20
23
|
export const FOLDER_MODEL_NAME = '@uniweb/folder'
|
|
21
24
|
export const FOLDER_ENTITY_KEY = '@folder'
|
|
22
25
|
|
|
23
|
-
// One record → a `ref` leaf.
|
|
26
|
+
// One record → a `ref` leaf. The folder's `contents` field is polymorphic (it can
|
|
27
|
+
// reference any Model), so the ref uses the entity_ref OPEN form `{ model, entity }`
|
|
28
|
+
// — not a bare uuid (a bare uuid is only valid when the field pins a single model).
|
|
29
|
+
// Known uuid → `entry: { model, entity: <uuid> }`; brand-new → `$ref` handle
|
|
30
|
+
// (resolved within this payload to the minted entity).
|
|
31
|
+
//
|
|
32
|
+
// TODO: the sync lane is uuid-keyed, so `model` should be the resolved Model UUID;
|
|
33
|
+
// it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
|
|
34
|
+
// resolution (a registry data-schema read) as a follow-up.
|
|
24
35
|
function refLeaf(entity) {
|
|
25
36
|
const leaf = { kind: 'ref', path_segment: entity.slug }
|
|
26
|
-
if (entity.uuid) leaf.entry = entity.uuid
|
|
37
|
+
if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
|
|
27
38
|
else leaf.$ref = entity.id // the `<collection>/<slug>` payload-local handle
|
|
28
39
|
return leaf
|
|
29
40
|
}
|
|
@@ -40,37 +51,37 @@ function groupByCollection(recordEntities) {
|
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
// Default org: one branch per collection (declaration order), records as leaves.
|
|
43
|
-
function
|
|
44
|
-
const
|
|
54
|
+
function defaultContents(groups) {
|
|
55
|
+
const contents = []
|
|
45
56
|
for (const [collection, records] of groups) {
|
|
46
|
-
|
|
57
|
+
contents.push({
|
|
47
58
|
kind: 'branch',
|
|
48
59
|
path_segment: collection,
|
|
49
|
-
|
|
60
|
+
$children: records.map(refLeaf),
|
|
50
61
|
})
|
|
51
62
|
}
|
|
52
|
-
return
|
|
63
|
+
return contents
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
// Virtual org from `collections.yml::folders`. Each node is either a collection
|
|
56
67
|
// NAME (string — expands to that collection's record leaves under a branch named
|
|
57
68
|
// after it) or a `{ segment, label?, entries: [...] }` branch (recursively).
|
|
58
|
-
function
|
|
69
|
+
function virtualContents(folders, groups) {
|
|
59
70
|
const buildNode = (node) => {
|
|
60
71
|
if (typeof node === 'string') {
|
|
61
72
|
const records = groups.get(node) || []
|
|
62
73
|
return {
|
|
63
74
|
kind: 'branch',
|
|
64
75
|
path_segment: node,
|
|
65
|
-
|
|
76
|
+
$children: records.map(refLeaf),
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
79
|
if (node && typeof node === 'object') {
|
|
69
80
|
const segment = node.segment ?? node.path_segment
|
|
70
81
|
const branch = { kind: 'branch', path_segment: segment }
|
|
71
|
-
if (node.label !== undefined) branch.
|
|
82
|
+
if (node.label !== undefined) branch.name = node.label
|
|
72
83
|
const children = Array.isArray(node.entries) ? node.entries : []
|
|
73
|
-
branch
|
|
84
|
+
branch.$children = children.flatMap((child) => {
|
|
74
85
|
// A bare collection name inside `entries:` expands to its leaves directly
|
|
75
86
|
// (so the records sit in THIS branch, not a nested one).
|
|
76
87
|
if (typeof child === 'string' && groups.has(child)) {
|
|
@@ -100,12 +111,12 @@ function virtualEntries(folders, groups) {
|
|
|
100
111
|
export function buildFolderEntity({ recordEntities, folders = null }) {
|
|
101
112
|
if (!Array.isArray(recordEntities) || recordEntities.length === 0) return null
|
|
102
113
|
const groups = groupByCollection(recordEntities)
|
|
103
|
-
const
|
|
114
|
+
const contents = folders ? virtualContents(folders, groups) : defaultContents(groups)
|
|
104
115
|
|
|
105
116
|
const document = {
|
|
106
117
|
$id: FOLDER_ENTITY_KEY,
|
|
107
118
|
$model: FOLDER_MODEL_NAME,
|
|
108
|
-
|
|
119
|
+
contents,
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
return {
|
package/src/uwx/site.js
CHANGED
|
@@ -132,7 +132,12 @@ function mapSectionData(section) {
|
|
|
132
132
|
function buildPageData(config, ctx) {
|
|
133
133
|
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations } =
|
|
134
134
|
ctx
|
|
135
|
-
|
|
135
|
+
// The page `slug` is the localized route source — a `{lang: slug}` map (the
|
|
136
|
+
// site-content Model declares it localized; greenlit 2026-06-13). A single-locale
|
|
137
|
+
// site emits one entry (`{ en: "home" }`); per-locale slug overrides for
|
|
138
|
+
// multi-locale localized routes (from i18n.routeTranslations) are follow-on
|
|
139
|
+
// producer work. `mode` is the plain delivery mode.
|
|
140
|
+
const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
|
|
136
141
|
setIf(data, 'stable_id', config.id)
|
|
137
142
|
setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
|
|
138
143
|
setIf(data, 'description', localizeScalar(config.description, sourceLocale, translations))
|
|
@@ -148,11 +153,25 @@ function buildPageData(config, ctx) {
|
|
|
148
153
|
setIf(data, 'rewrite', config.rewrite)
|
|
149
154
|
setIf(data, 'layout', config.layout)
|
|
150
155
|
setIf(data, 'seo', config.seo)
|
|
151
|
-
|
|
156
|
+
let fetch =
|
|
152
157
|
config.fetch ??
|
|
153
158
|
(config.data
|
|
154
159
|
? { collection: Array.isArray(config.data) ? config.data[0] : config.data }
|
|
155
160
|
: undefined)
|
|
161
|
+
// Resolve the build-time `collection:` shorthand to the runtime-fetchable
|
|
162
|
+
// `path: /data/<name>.json` (the static convention the default-fetcher uses).
|
|
163
|
+
// A shell/backend-hosted site renders client-side with NO prerender, so the
|
|
164
|
+
// runtime fetches this decl directly — and `collection:` is build-time-only, so
|
|
165
|
+
// it would never resolve at render (the static build resolves it the same way
|
|
166
|
+
// in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
|
|
167
|
+
// at `<base>/data/<name>.json`.
|
|
168
|
+
if (fetch && typeof fetch.collection === 'string') {
|
|
169
|
+
const { collection, ...rest } = fetch
|
|
170
|
+
// `schema` (the collection name) is BOTH the content.data key and part of the
|
|
171
|
+
// dataStore cache key (deriveCacheKey hashes {path,url,schema,…}; `collection`
|
|
172
|
+
// is ignored). Mirrors the static build's parseFetchConfig resolution.
|
|
173
|
+
fetch = { path: `/data/${collection}.json`, schema: collection, ...rest }
|
|
174
|
+
}
|
|
156
175
|
setIf(data, 'fetch', fetch)
|
|
157
176
|
if (isDynamic) {
|
|
158
177
|
data.is_dynamic = true
|