@uniweb/build 0.14.11 → 0.14.13

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.13",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,21 +60,21 @@
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
62
  "@uniweb/content-writer": "0.2.5",
63
- "@uniweb/theming": "0.1.3"
63
+ "@uniweb/theming": "0.1.4"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/schemas": "0.2.3",
66
+ "@uniweb/runtime": "0.8.18",
67
67
  "@uniweb/content-reader": "1.1.12",
68
- "@uniweb/runtime": "0.8.16"
68
+ "@uniweb/schemas": "0.2.3"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
72
- "react": "^18.0.0 || ^19.0.0",
73
- "react-dom": "^18.0.0 || ^19.0.0",
72
+ "react": "^19.0.0",
73
+ "react-dom": "^19.0.0",
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.7.12"
77
+ "@uniweb/core": "0.7.13"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -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 {
@@ -99,6 +99,7 @@ const INFO_TO_SITE_YML = {
99
99
  search: 'search',
100
100
  paths: 'paths',
101
101
  data: 'data',
102
+ template: 'template',
102
103
  }
103
104
 
104
105
  /**
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
@@ -535,6 +554,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
535
554
  setIf(info, 'search', siteYml.search)
536
555
  setIf(info, 'paths', siteYml.paths)
537
556
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
557
+ // `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
558
+ // backend applies a clonability designation to this site-content entity (it is
559
+ // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
560
+ setIf(info, 'template', siteYml.template)
538
561
 
539
562
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
540
563
  const pagesPath = siteYml.paths?.pages
@@ -13,6 +13,7 @@ import { join, resolve } from 'node:path'
13
13
  import { buildSchema } from './schema.js'
14
14
  import { generateEntryPoint, shouldRegenerateForFile } from './generate-entry.js'
15
15
  import { processAllPreviews } from './images.js'
16
+ import { generateFoundationVars } from './theme/index.js'
16
17
 
17
18
  /**
18
19
  * Build schema.json with preview image references
@@ -300,6 +301,45 @@ async function buildSSRBundle(outDir) {
300
301
  }
301
302
  }
302
303
 
304
+ /**
305
+ * Append the foundation's theme-variable DEFAULTS as a `:root{}` baseline to the
306
+ * built CSS (`assets/style.css`), so a runtime-loaded foundation carries its own
307
+ * var defaults wherever it loads (registry ref / URL — where the site build can't
308
+ * read them). Context-aware vars (color/gradient) are excluded: those are applied
309
+ * per light/dark context by the site theme, not as a flat default. Idempotent
310
+ * (marker-guarded), and a no-op when the foundation declares no vars or ships no
311
+ * stylesheet to carry them.
312
+ */
313
+ async function emitFoundationVarsCss(outDir, schema) {
314
+ const rawVars = schema?._self?.vars
315
+ if (!rawVars || Object.keys(rawVars).length === 0) return
316
+
317
+ const CONTEXT_AWARE = new Set(['color', 'gradient'])
318
+ const flatVars = Object.fromEntries(
319
+ Object.entries(rawVars).filter(
320
+ ([, cfg]) => !(cfg && typeof cfg === 'object' && CONTEXT_AWARE.has(cfg.type))
321
+ )
322
+ )
323
+
324
+ const rootCss = generateFoundationVars(flatVars)
325
+ if (!rootCss) return
326
+
327
+ const cssPath = join(outDir, 'assets', 'style.css')
328
+ if (!existsSync(cssPath)) {
329
+ console.warn(
330
+ `Foundation declares ${Object.keys(flatVars).length} theme var(s) but has no assets/style.css to carry their defaults — skipped.`
331
+ )
332
+ return
333
+ }
334
+
335
+ const marker = '/* uniweb:foundation-var-defaults */'
336
+ const existing = await readFile(cssPath, 'utf-8')
337
+ if (existing.includes(marker)) return
338
+
339
+ await writeFile(cssPath, `${existing.trimEnd()}\n\n${marker}\n${rootCss}\n`, 'utf-8')
340
+ console.log(`Emitted ${Object.keys(flatVars).length} foundation theme-var default(s) to assets/style.css`)
341
+ }
342
+
303
343
  /**
304
344
  * Vite plugin for foundation builds
305
345
  */
@@ -359,6 +399,18 @@ export function foundationBuildPlugin(options = {}) {
359
399
 
360
400
  console.log(`Generated meta/schema.json with ${Object.keys(schema).length - 1} components`)
361
401
 
402
+ // Emit the foundation's theme-variable DEFAULTS as a :root{} baseline into
403
+ // the delivered CSS. A runtime-loaded foundation (registry ref / URL) is
404
+ // loaded with only its dist/ — the site build can't read these defaults
405
+ // (they live in schema._self.vars, which the site never sees), so without
406
+ // this the foundation renders with its theme vars undefined (e.g. collapsed
407
+ // section spacing where components use py-[var(--section-padding-y)]).
408
+ // Shipping the defaults in the foundation's own CSS makes it self-sufficient
409
+ // in every load mode; a site's theme.yml overrides still win (the site theme
410
+ // loads after the foundation CSS). Bundled sites already get these via the
411
+ // site build's theme.css — this is harmless redundancy there.
412
+ await emitFoundationVarsCss(outDir, schema)
413
+
362
414
  // Emit runtime-pin.json so the edge isolate (under Strategy S) can
363
415
  // side-load the matching runtime/{ver}/ssr.js. Lands silently before
364
416
  // the dual-mode resolver ships; foundations published in the dual-mode