@uniweb/build 0.16.16 → 0.16.17

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.16.16",
3
+ "version": "0.16.17",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,15 +59,15 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/content-writer": "0.3.3",
62
+ "@uniweb/theming": "0.1.15",
63
63
  "@uniweb/projections": "0.2.5",
64
- "@uniweb/theming": "0.1.15"
64
+ "@uniweb/content-writer": "0.3.3"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/content-reader": "1.2.2",
68
- "@uniweb/runtime": "0.9.7",
68
+ "@uniweb/semantic-parser": "1.2.1",
69
69
  "@uniweb/schemas": "0.2.5",
70
- "@uniweb/semantic-parser": "1.2.1"
70
+ "@uniweb/runtime": "0.9.7"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/dev/plugin.js CHANGED
@@ -26,6 +26,7 @@ import { readFile } from 'node:fs/promises'
26
26
  import { existsSync, readdirSync } from 'node:fs'
27
27
  import { build } from 'vite'
28
28
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
29
+ import { DEV_REBUILD_MARKER } from '../vite-foundation-plugin.js'
29
30
 
30
31
  /** Directories that never hold foundation source and must never be walked. */
31
32
  const UNWATCHABLE_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage'])
@@ -126,6 +127,13 @@ export function foundationDevPlugin(options = {}) {
126
127
  root: resolvedFoundationPath,
127
128
  configFile: existsSync(configPath) ? configPath : false,
128
129
  logLevel: 'warn',
130
+ // Marks this as a dev rebuild so the foundation plugin skips the
131
+ // `entry-ssr.js` sub-build — a second full Vite pass, on every save,
132
+ // producing something nothing in the dev loop reads. A marker plugin
133
+ // rather than an option because the foundation's own vite.config.js
134
+ // constructs the plugin, not us; and rather than `mode`/`command`,
135
+ // which both say "build" here since this IS a real build.
136
+ plugins: [{ name: DEV_REBUILD_MARKER }],
129
137
  build: {
130
138
  outDir: 'dist',
131
139
  emptyOutDir: true,
@@ -29,6 +29,14 @@ const TEXT_KINDS = new Set(['string', 'text'])
29
29
  // carrying one round-trips as the RAW source string (no ProseMirror conversion).
30
30
  const CONTENT_TEXT_FORMATS = new Set(['markdown', 'html'])
31
31
 
32
+ // The field an open map's key lowers into. `name` matches the idiom already in use
33
+ // for this shape (the backend's site-content `collections` section), so an open map
34
+ // and a hand-authored row set produce the same wire shape rather than two spellings
35
+ // of one thing. Not configurable on purpose: a second way to spell it would be a
36
+ // vocabulary addition for a collision that does not exist yet — a value schema that
37
+ // declares its own `name` is an error instead, which names the problem precisely.
38
+ const OPEN_MAP_KEY = 'name'
39
+
32
40
  /**
33
41
  * A `text` field marked as rich content (`format: markdown` or `html`): the
34
42
  * file-based body target. Round-trips as the raw source string — what the retired
@@ -119,7 +127,7 @@ function lowerSectionsForm(sectionsMap, resolve, optResolve) {
119
127
  let explicit = null
120
128
  let firstSingle = null
121
129
  for (const [secName, def] of Object.entries(sectionsMap)) {
122
- sections[secName] = lowerSection(def, resolve, optResolve)
130
+ sections[secName] = lowerSection(def, resolve, optResolve, secName)
123
131
  if (def.brief === true) explicit = secName
124
132
  if (!firstSingle && (def.kind || 'single') === 'single') firstSingle = secName
125
133
  }
@@ -138,7 +146,7 @@ function lowerSectionsForm(sectionsMap, resolve, optResolve) {
138
146
  // are type: section"); `nestable` → `self_nesting`; `append_only` (insert-only
139
147
  // records) passes through; authored cross-cutting `constraints` pass through as a
140
148
  // bare array. Leaves and nested sections share one ordered `fields:` namespace.
141
- function lowerSection(def, resolve, optResolve) {
149
+ function lowerSection(def, resolve, optResolve, path = '') {
142
150
  const out = {}
143
151
  if ((def.kind || 'single') === 'multi') out.multiple = true
144
152
  if (def.brief === true) out.brief = true
@@ -147,14 +155,29 @@ function lowerSection(def, resolve, optResolve) {
147
155
 
148
156
  const fields = {}
149
157
  for (const [key, rawField] of Object.entries(def.fields || {})) {
150
- fields[key] = lowerField(rawField, resolve, optResolve)
158
+ fields[key] = lowerField(rawField, resolve, optResolve, path ? `${path}/${key}` : key)
151
159
  }
152
160
  // Explicit child sections (sections-form, e.g. under a binder) → `type: section`
153
161
  // fields, in the same ordered namespace as the leaves.
154
162
  for (const [childName, childDef] of Object.entries(def.sections || {})) {
155
- fields[childName] = { type: 'section', ...lowerSection(childDef, resolve, optResolve) }
163
+ const childPath = path ? `${path}/${childName}` : childName
164
+ fields[childName] = {
165
+ type: 'section',
166
+ ...lowerSection(childDef, resolve, optResolve, childPath)
167
+ }
156
168
  }
157
- if (Object.keys(fields).length) out.fields = fields
169
+ // A section with neither leaves nor sub-sections carries nothing, and is not a
170
+ // valid section on this wire by either party's reckoning. Refusing here fails at
171
+ // the schema author's screen; emitting it fails in a consumer's restore, which is
172
+ // the last possible moment and the wrong screen (2026-08-04: `@std/form` shipped
173
+ // exactly this, because `values:` had no lowering and silently produced no fields).
174
+ if (!Object.keys(fields).length) {
175
+ throw new Error(
176
+ `Data schema: section '${path || '(root)'}' declares no fields and no sub-sections. ` +
177
+ `A section must carry at least one leaf or child section.`
178
+ )
179
+ }
180
+ out.fields = fields
158
181
  if (Array.isArray(def.constraints) && def.constraints.length) out.constraints = def.constraints
159
182
  return out
160
183
  }
@@ -166,17 +189,69 @@ function lowerSection(def, resolve, optResolve) {
166
189
  // array of ref → entity_ref + multiple
167
190
  // array of scalar → the scalar kind + multiple
168
191
  // ref → entity_ref (model by name)
169
- function lowerField(rawField, resolve, optResolve) {
192
+ function lowerField(rawField, resolve, optResolve, path = '') {
170
193
  const field = asField(rawField)
171
194
  const type = field.type
172
195
 
173
196
  if (type === 'object') {
174
- return { type: 'section', ...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve) }
197
+ // An OPEN MAP (`values:`) is ROWS, not a singleton. Its keys belong to the
198
+ // author, which makes them data — so the map lowers to a `multi` section whose
199
+ // key field carries what was the object key, with a section-scoped uniqueness
200
+ // rule making that key the row's identity. This is the same shape `array of
201
+ // object` already lowers to, and the idiom the backend's own site-content
202
+ // `collections` section uses; no new wire construct is involved.
203
+ //
204
+ // Identity is the KEY, never row position — a round-trip that rebuilds the map
205
+ // from order looks correct and drifts the first time rows are reordered.
206
+ // Authoring order is still preserved into row order, because for a form the
207
+ // field order is what the visitor sees.
208
+ if (field.values !== undefined) {
209
+ const value = asField(field.values)
210
+ if (value.type !== 'object' || !value.fields) {
211
+ throw new Error(
212
+ `Data schema: open map at '${path}' declares 'values' that is not an object with ` +
213
+ `'fields'. An open map lowers to rows, and a row needs declared columns.`
214
+ )
215
+ }
216
+ if (value.fields[OPEN_MAP_KEY]) {
217
+ throw new Error(
218
+ `Data schema: open map at '${path}' has a value field named '${OPEN_MAP_KEY}', which ` +
219
+ `is the field the map's key lowers into. Rename that field.`
220
+ )
221
+ }
222
+ return {
223
+ type: 'section',
224
+ ...lowerSection(
225
+ {
226
+ kind: 'multi',
227
+ // `translatable: false` is load-bearing, not tidiness: a string field is
228
+ // localized by default, and a localized key could differ per locale —
229
+ // which would destroy the identity the key exists to carry. The key is an
230
+ // identifier, never content.
231
+ fields: {
232
+ [OPEN_MAP_KEY]: { type: 'string', required: true, translatable: false },
233
+ ...value.fields
234
+ },
235
+ constraints: [{ kind: 'unique_field', field: OPEN_MAP_KEY, scope: 'section' }]
236
+ },
237
+ resolve,
238
+ optResolve,
239
+ path
240
+ )
241
+ }
242
+ }
243
+ return {
244
+ type: 'section',
245
+ ...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve, path)
246
+ }
175
247
  }
176
248
  if (type === 'array') {
177
249
  const items = field.items ? asField(field.items) : null
178
250
  if (items && items.type === 'object') {
179
- return { type: 'section', ...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve) }
251
+ return {
252
+ type: 'section',
253
+ ...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve, path)
254
+ }
180
255
  }
181
256
  if (items && items.type === 'ref') {
182
257
  // Multi-valued reference — the per-field `multiple` flag (the `array` Kind
@@ -466,6 +466,22 @@ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
466
466
  }
467
467
  }
468
468
 
469
+ /**
470
+ * Marks a foundation build as a DEV-server rebuild.
471
+ *
472
+ * The dev server runs a real Vite `build()` of the foundation on every watched
473
+ * change (`dev/plugin.js`), so `command`, `mode` and `isProduction` cannot tell
474
+ * a dev rebuild from a shipping build — all of them say "build". An injected
475
+ * marker plugin can, and it survives the foundation's own `vite.config.js`
476
+ * being merged in, which a plugin *option* would not (the dev server does not
477
+ * construct the foundation plugin — the foundation's config file does).
478
+ *
479
+ * Used to skip the `entry-ssr.js` sub-build in dev: it is a second full Vite
480
+ * pass per save, and nothing in the dev loop reads it (dev never ships, and the
481
+ * SSR lanes that run locally — SSG prerender and unipress — load `entry.js`).
482
+ */
483
+ export const DEV_REBUILD_MARKER = 'uniweb:dev-foundation-rebuild'
484
+
469
485
  /**
470
486
  * Vite plugin for foundation builds
471
487
  */
@@ -481,6 +497,7 @@ export function foundationBuildPlugin(options = {}) {
481
497
  let resolvedOutDir
482
498
  let resolvedRoot
483
499
  let isProduction
500
+ let isDevRebuild = false
484
501
 
485
502
  return {
486
503
  name: 'uniweb-foundation-build',
@@ -500,6 +517,7 @@ export function foundationBuildPlugin(options = {}) {
500
517
  resolvedOutDir = config.build.outDir
501
518
  resolvedRoot = config.root
502
519
  isProduction = config.mode === 'production'
520
+ isDevRebuild = (config.plugins || []).some((p) => p?.name === DEV_REBUILD_MARKER)
503
521
  },
504
522
 
505
523
  async writeBundle() {
@@ -554,8 +572,20 @@ export function foundationBuildPlugin(options = {}) {
554
572
  //
555
573
  // (The legacy self-contained buildSSRBundle() — React + runtime INLINED,
556
574
  // ~14 MB with Shiki — is retained below, unused, for reference only.)
557
- const entrySourcePath = join(resolvedSrcDir, entryFileName)
558
- await buildEntrySSR(resolvedRoot, entrySourcePath, outDir)
575
+ // Skipped on a DEV rebuild: this is a second full Vite pass, it runs on
576
+ // every save, and nothing in the dev loop reads its output. The local SSR
577
+ // lanes load `entry.js` (SSG prerender via `import()`, unipress the same);
578
+ // only the edge isolate needs the single-file twin, and dev ships nothing
579
+ // to it.
580
+ //
581
+ // Shipping lanes are unaffected — `uniweb build`, and `register`/`publish`
582
+ // which build through it. `register`'s build-if-stale check also requires
583
+ // `entry-ssr.js`, so a dist left behind by a dev session is treated as
584
+ // stale and rebuilt rather than uploaded without one.
585
+ if (!isDevRebuild) {
586
+ const entrySourcePath = join(resolvedSrcDir, entryFileName)
587
+ await buildEntrySSR(resolvedRoot, entrySourcePath, outDir)
588
+ }
559
589
  },
560
590
 
561
591
  async closeBundle() {