@uniweb/build 0.16.15 → 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.15",
3
+ "version": "0.16.17",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,10 +64,10 @@
64
64
  "@uniweb/content-writer": "0.3.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "0.9.7",
68
67
  "@uniweb/content-reader": "1.2.2",
69
68
  "@uniweb/semantic-parser": "1.2.1",
70
- "@uniweb/schemas": "0.2.5"
69
+ "@uniweb/schemas": "0.2.5",
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,
@@ -113,22 +113,50 @@ export function detectFoundationType(foundation, siteRoot) {
113
113
  }
114
114
  }
115
115
 
116
- // Catalog registry ref:
117
- // `@org/name@version` → resolves via the registry CDN.
118
- // Link-mode by definition the foundation lives on the hosting edge and is
119
- // loaded at runtime. Surfacing this as `type: 'url'` makes Vite skip the
120
- // local-foundation bundling path and use the noop virtual module. Base URL
121
- // defaults to the production worker but is overridable via UNIWEB_REGISTRY_URL
122
- // for self-hosted / staging.
116
+ // Catalog registry ref: `@org/name@version`.
117
+ //
118
+ // A build does NOT turn this into a URL. Where a foundation is served is the
119
+ // host's to say a serve location is READ (from backend discovery, or from an
120
+ // upload plan's `serve_base`), never reconstructed. `@uniweb/cli`'s
121
+ // DISCOVERY_DEFAULTS carries no serve-root default for exactly this reason.
122
+ // A build is offline and backend-optional by design, so it has nothing to ask.
123
+ //
124
+ // A ref names a foundation in the Uniweb platform's catalog, and `uniweb
125
+ // publish` — the verb reserved for that target — has the platform resolve it,
126
+ // running no vite build. That is one hosting target among many. The others
127
+ // reach a host through `uniweb deploy --host=<adapter>` or `uniweb export`,
128
+ // and both need a concrete URL, which the site declares: the runtime accepts
129
+ // any URL, from any host.
130
+ //
131
+ // Until 2026-08-04 this returned `{base}/foundations/{ns}/{name}/{ver}/foundation.js`
132
+ // against a hardcoded host, overridable only through an env var that did not
133
+ // match the documented backend selection — so `--backend`, `uniweb login
134
+ // --backend` and the documented env var all left it pinned — and the artifact
135
+ // names were the pre-`entry.js` ones the build stopped emitting.
123
136
  const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
124
137
  if (orgScopedMatch) {
125
- const base = process.env.UNIWEB_REGISTRY_URL || 'https://site-router.uniweb-edge.workers.dev'
126
- const [, ns, fn, ver] = orgScopedMatch
127
- return {
128
- type: 'url',
129
- url: `${base}/foundations/${ns}/${fn}/${ver}/foundation.js`,
130
- cssUrl: `${base}/foundations/${ns}/${fn}/${ver}/assets/foundation.css`
131
- }
138
+ throw new Error(
139
+ [
140
+ `Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
141
+ `Where a foundation is served is the host's to declare, so the build does not guess it.`,
142
+ ``,
143
+ ` • Deploying to another host (\`uniweb deploy --host=<adapter>\`), or taking`,
144
+ ` the build anywhere (\`uniweb export\`)? Declare the served URL in site.yml —`,
145
+ ` the runtime accepts any URL, from any host:`,
146
+ ``,
147
+ ` foundation: https://<host>/<path>/entry.js`,
148
+ ``,
149
+ ` or the object form when the stylesheet sits elsewhere:`,
150
+ ``,
151
+ ` foundation: { url: 'https://…/entry.js', cssUrl: 'https://…/assets/style.css' }`,
152
+ ``,
153
+ ` • Iterating locally?`,
154
+ ` Reference the workspace foundation by package name.`,
155
+ ``,
156
+ ` • Targeting the Uniweb platform?`,
157
+ ` \`uniweb publish\` has the platform resolve the ref — no build-time URL needed.`
158
+ ].join('\n')
159
+ )
132
160
  }
133
161
 
134
162
  // Versionless scoped names (`@org/name`) are valid as *handles* — they
@@ -290,15 +318,14 @@ export async function defineSiteConfig(options = {}) {
290
318
  // Read site.yml
291
319
  const siteConfig = readSiteConfig(siteRoot)
292
320
 
293
- // Allow callers to override `foundation:` without modifying site.yml on
294
- // disk. Used by `uniweb deploy` to substitute a workspace-local file: ref
295
- // with the resolved registry ref (`@ns/name@ver`) for the duration of the
296
- // deploy build, so the site builds in runtime/link mode against the just-
297
- // published artifact instead of bundling the local source.
298
- const foundationOverride = process.env.UNIWEB_FOUNDATION_REF
299
- if (foundationOverride) {
300
- siteConfig.foundation = foundationOverride
301
- }
321
+ // `site.yml` is the only place a site's foundation is declared. A
322
+ // `UNIWEB_FOUNDATION_REF` env override lived here until 2026-08-04, silently
323
+ // substituting a different foundation for the duration of a build. It served
324
+ // the `uniweb deploy` auto-publish flow, which was removed; after that no
325
+ // command set it, so what remained was an invisible way for a site to be
326
+ // rendered by code its own config did not name. Removed rather than kept as a
327
+ // manual escape hatch — which foundation renders a site is exactly the thing
328
+ // that should never be true-but-unstated.
302
329
 
303
330
  // Determine base path for deployment (priority: option > env > site.yml)
304
331
  // Normalize: ensure leading slash, collapse repeated slashes, add trailing slash for Vite
@@ -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() {