@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.14.11",
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/content-writer": "0.2.5",
63
- "@uniweb/theming": "0.1.3"
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/runtime": "0.8.16"
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",
@@ -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
- return localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
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
- // The brief is the section marked `brief: true` (the sections-tree has no
147
- // schema-level `brief:` back-reference); its `fields` is a map keyed by name.
148
- const briefEntry = Object.entries(declaration.sections || {}).find(([, s]) => s && s.brief === true)
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
- const brief = briefEntry?.[1]
151
- if (!brief) {
152
- throw new Error(
153
- `uwx/collections: Model ${declaration.name} has no brief section ` +
154
- 'v1 maps flat records to the brief single section only'
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` collection record is the value of the brief's
161
- // CONTENT field — a markup `text` field (raw source string) or a `format:
162
- // prosemirror` json field (docs/reference/entity-content.md). One content field is
163
- // the body target; zero means a `.md` body has nowhere to go (warn per record).
164
- const contentEntries = Object.entries(briefFields).filter(([, f]) => isContentBodyField(f))
165
- const bodyFieldKey = contentEntries[0]?.[0] || null
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 (contentEntries.length > 1) {
192
+ if (contentMatches.length > 1) {
170
193
  warnings.push(
171
- `${collectionName}: ${declaration.name}.${briefName} has more than one content ` +
172
- `(markdown / html / prosemirror) field — the markdown body maps to "${bodyFieldKey}"`
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
- // Brief section data in schema-declared field order (the wire's canonical
189
- // order). An absent field is simply omitted an incomplete entity is a
190
- // valid stored state; the foundation copes at render time. The markdown body
191
- // fills the content body field unless the frontmatter already set it explicitly.
192
- const data = {}
193
- for (const [key, field] of Object.entries(briefFields)) {
194
- let value = record[key]
195
- if (value === undefined && key === bodyFieldKey && hasBody) value = record.$body
196
- if (value === undefined) continue
197
- const encoded = encodeFieldValue(value, field, sourceLocale, translations)
198
- if (encoded !== undefined) data[key] = encoded
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 that aren't on the Model. A real unknown key means the
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}.${briefName} — not synced`
238
+ `${declaration.name} — not synced`
208
239
  )
209
240
  }
210
- if (hasBody && !bodyFieldKey) {
241
+ if (hasBody && !bodyTarget) {
211
242
  warnings.push(
212
243
  `${collectionName}/${slug}: markdown body present but ` +
213
- `${declaration.name}.${briefName} has no content body field — body not synced`
244
+ `${declaration.name} has no content body field — body not synced`
214
245
  )
215
246
  }
216
247
 
217
- // The `$`-document body, in canonical key order: `$uuid?`, `$id`, `$model`,
218
- // then the brief section. `$owner`/`$unit`/`$meta` are omitted — the backend
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] = data
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. The folder holds REFERENCES, never content:
6
- //
7
- // - a LEAF references one record entity, by `$ref: "<collection>/<slug>"` when the
8
- // record has no `$uuid` yet (resolved within this payload), or `entry: <uuid>`
9
- // when it was minted on an earlier sync (back-filled into the record's file).
10
- // - a BRANCH is a sub-folder (`path_segment` + nested `entries`).
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. Known uuid `entry`; brand-new `$ref` handle.
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 defaultEntries(groups) {
44
- const entries = []
54
+ function defaultContents(groups) {
55
+ const contents = []
45
56
  for (const [collection, records] of groups) {
46
- entries.push({
57
+ contents.push({
47
58
  kind: 'branch',
48
59
  path_segment: collection,
49
- entries: records.map(refLeaf),
60
+ $children: records.map(refLeaf),
50
61
  })
51
62
  }
52
- return entries
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 virtualEntries(folders, groups) {
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
- entries: records.map(refLeaf),
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.label = node.label
82
+ if (node.label !== undefined) branch.name = node.label
72
83
  const children = Array.isArray(node.entries) ? node.entries : []
73
- branch.entries = children.flatMap((child) => {
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 entries = folders ? virtualEntries(folders, groups) : defaultEntries(groups)
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
- entries,
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
- const data = { slug, mode } // both required by the entity type
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
- const fetch =
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