@uniweb/build 0.64.1 → 0.64.2

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.64.1",
3
+ "version": "0.64.2",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,11 +57,11 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
+ "@uniweb/schemas": "^0.7.2",
60
61
  "@uniweb/content-reader": "^1.2.5",
61
62
  "@uniweb/content-writer": "^0.3.4",
62
63
  "@uniweb/projections": "^0.7.4",
63
64
  "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/schemas": "^0.7.1",
65
65
  "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
@@ -74,8 +74,8 @@
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.30.1",
78
- "@uniweb/runtime": "^0.27.6"
77
+ "@uniweb/runtime": "^0.27.6",
78
+ "@uniweb/core": "^0.30.1"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -85,6 +85,9 @@ export const bridgeFileName = (spec) => `${spec.replace(/\//g, '-')}.js`
85
85
  * @param {Object} [options.devBridges] - Map of specifier → dev-mode URL for import map injection in dev.
86
86
  * When provided, the import map is injected in both dev and prod (with different URLs).
87
87
  * When omitted, the import map is only injected in prod (dev uses other mechanisms like transformRequest).
88
+ * @param {boolean} [options.serveInDev] - In dev, point each external at a bridge the dev server serves
89
+ * itself — the module a build would emit, through Vite's pipeline, so its bare imports become the very
90
+ * URLs the host app imports. For a module loaded from a URL, which no dev plugin rewrites.
88
91
  * @returns {import('vite').Plugin}
89
92
  */
90
93
  export function importMapPlugin({
@@ -93,14 +96,17 @@ export function importMapPlugin({
93
96
  basePath = '/',
94
97
  resolveFrom,
95
98
  devBridges,
99
+ serveInDev = false,
96
100
  } = {}) {
97
101
  let isBuild = false
102
+ let devBase = '/'
98
103
 
99
104
  return {
100
105
  name,
101
106
 
102
107
  configResolved(config) {
103
108
  isBuild = config.command === 'build'
109
+ devBase = config.base || '/'
104
110
  },
105
111
 
106
112
  resolveId(id, importer) {
@@ -199,6 +205,13 @@ export function importMapPlugin({
199
205
  }
200
206
  } else if (devBridges) {
201
207
  Object.assign(imports, devBridges)
208
+ } else if (serveInDev) {
209
+ // Vite serves a virtual module at `/@id/`, its NUL written `__x00__` — the id `load`
210
+ // answers below, so dev and build bridge one module the same way.
211
+ for (const ext of externals) {
212
+ const spec = specOf(ext)
213
+ imports[spec] = `${devBase}@id/__x00__${IMPORT_MAP_PREFIX.slice(1)}${spec}`
214
+ }
202
215
  } else {
203
216
  // No dev injection — consumer handles dev mode separately
204
217
  return html
@@ -20,7 +20,7 @@
20
20
  * })
21
21
  */
22
22
 
23
- import { existsSync, readFileSync } from 'node:fs'
23
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
24
24
  import { resolve, dirname, join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -34,6 +34,8 @@ import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
34
34
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
35
35
  import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
36
36
  import { detectFoundationType } from './foundation-ref.js'
37
+ import { readPreview, PREVIEW_ENV } from './preview.js'
38
+ import { previewMediaPlugin } from './preview-media.js'
37
39
 
38
40
  /**
39
41
  * Normalize a base path for Vite compatibility
@@ -112,6 +114,25 @@ function unique(list) {
112
114
  return [...new Set(list)]
113
115
  }
114
116
 
117
+ /**
118
+ * The site's `@uniweb/runtime`, as a path to resolve its dependencies from — its REAL
119
+ * directory, since under pnpm a package's dependencies sit beside its real location and
120
+ * not beside the link to it. Null when the site has none installed.
121
+ */
122
+ function runtimeAnchor(siteRoot) {
123
+ for (let dir = siteRoot; ; dir = dirname(dir)) {
124
+ const candidate = join(dir, 'node_modules', '@uniweb', 'runtime')
125
+ if (existsSync(join(candidate, 'package.json'))) {
126
+ try {
127
+ return join(realpathSync(candidate), 'package.json')
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+ if (dirname(dir) === dir) return null
133
+ }
134
+ }
135
+
115
136
  export async function defineSiteConfig(options = {}) {
116
137
  const {
117
138
  plugins: extraPlugins = [],
@@ -147,8 +168,15 @@ export async function defineSiteConfig(options = {}) {
147
168
  const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
148
169
  const base = rawBase ? normalizeBasePath(String(rawBase)) : undefined
149
170
 
150
- // Detect foundation type
151
- const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
171
+ // What `uniweb dev` read from the backend the site is on, for this dev server alone
172
+ // (`./preview.js`) — null unless it was handed one.
173
+ const preview = readPreview(process.env)
174
+
175
+ // Detect foundation type. A catalog ref resolves only for a preview, which is handed where
176
+ // the backend serves it.
177
+ const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot, {
178
+ served: preview?.foundation
179
+ })
152
180
 
153
181
  // Check for runtime mode (env variable or URL-based foundation).
154
182
  // Runtime mode means the foundation is loaded by URL at runtime; the
@@ -376,13 +404,39 @@ export async function defineSiteConfig(options = {}) {
376
404
  plugins.push(importMapPlugin({
377
405
  basePath: base || '/',
378
406
  // Under pnpm strict mode, the site may not have @uniweb/core in its own
379
- // node_modules. Resolve from the foundation directory where it's a direct dep.
407
+ // node_modules. Resolve from the foundation directory where it's a direct dep —
408
+ // or, with no local foundation, as the site's runtime resolves it: the runtime is
409
+ // the host app whose instances a foundation must share.
380
410
  resolveFrom: foundationInfo.path
381
411
  ? resolve(foundationInfo.path, 'package.json')
382
- : resolve(siteRoot, 'main.js'),
412
+ : runtimeAnchor(siteRoot) || resolve(siteRoot, 'main.js'),
413
+ // A foundation served from a URL imports its externals by bare name, and in dev
414
+ // nothing else resolves them: the foundation dev plugin rewrites a LOCAL
415
+ // foundation's imports, and a served one is not ours to rewrite. So the dev server
416
+ // answers the import map a build writes, bridging to the site's own copies.
417
+ serveInDev: foundationInfo.type === 'url'
383
418
  }))
384
419
  }
385
420
 
421
+ if (preview) {
422
+ // ⛔ A PREVIEW'S ANSWER NEVER REACHES A BUILT ARTIFACT. Where the backend serves a
423
+ // version is decided at publish and read for one preview (`./preview.js`); written into
424
+ // `dist/` it would outlive the answer.
425
+ plugins.push({
426
+ name: 'uniweb:preview-only',
427
+ config(_config, env) {
428
+ if (env.command === 'build') {
429
+ throw new Error(
430
+ `[site] ${PREVIEW_ENV} is what \`uniweb dev\` read from ${preview.backend} for one ` +
431
+ `preview, and a build does not take it. Unset it to build.`
432
+ )
433
+ }
434
+ }
435
+ })
436
+ // A media URL the site keeps from its backend is fetched from that backend.
437
+ plugins.push(previewMediaPlugin({ backend: preview.backend, siteRoot }))
438
+ }
439
+
386
440
  // Preload hints for runtime-loaded foundations and extensions.
387
441
  // In runtime mode, foundation JS is loaded via import() and CSS is injected
388
442
  // dynamically in JavaScript — the browser doesn't discover them until JS executes.
@@ -66,11 +66,15 @@ export function parseCatalogRef(value) {
66
66
  *
67
67
  * @param {string|Object} foundation - Foundation config from site.yml
68
68
  * @param {string} siteRoot - Path to site directory
69
- * @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string }}
69
+ * @param {Object} [options]
70
+ * @param {{ ref: string, url: string, cssUrl?: string|null }|null} [options.served] - where a
71
+ * catalog ref is served, for a preview: the answer `uniweb dev` read from the backend the site
72
+ * is on (`./preview.js`). Honored for the ref it names and no other.
73
+ * @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string, served?: string }}
70
74
  * @throws {Error} when the declaration shape is invalid (versionless registry
71
75
  * ref, unknown name with no local match, etc.)
72
76
  */
73
- export function detectFoundationType(foundation, siteRoot) {
77
+ export function detectFoundationType(foundation, siteRoot, { served = null } = {}) {
74
78
  // Object form with explicit URL
75
79
  if (foundation && typeof foundation === 'object') {
76
80
  if (foundation.url) {
@@ -119,11 +123,26 @@ export function detectFoundationType(foundation, siteRoot) {
119
123
  // --backend` and the documented env var all left it pinned — and the artifact
120
124
  // names were the pre-`entry.js` ones the build stopped emitting.
121
125
  if (parseCatalogRef(name)) {
126
+ // ⭐ A PREVIEW is handed the answer instead of guessing it: `uniweb dev` asks the backend
127
+ // the site is on where this version is served, for its one dev server. The answer names
128
+ // the ref it is for, so it resolves the DECLARED foundation and can substitute no other.
129
+ if (served?.url && served.ref === name) {
130
+ return { type: 'url', url: served.url, cssUrl: served.cssUrl || null, served: name }
131
+ }
132
+ if (served?.ref) {
133
+ throw new Error(
134
+ `This dev server was handed where "${served.ref}" is served, and site.yml names "${name}".\n` +
135
+ `Restart \`uniweb dev\`, which asks for the foundation site.yml names.`
136
+ )
137
+ }
122
138
  throw new Error(
123
139
  [
124
140
  `Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
125
141
  `Where a foundation is served is the host's to declare, so the build does not guess it.`,
126
142
  ``,
143
+ ` • Previewing the site? Run \`uniweb dev\`, which asks the backend the site is on`,
144
+ ` where this version is served — and says so when that backend does not tell it.`,
145
+ ``,
127
146
  ` • Deploying to another host (\`uniweb deploy --host=<adapter>\`), or taking`,
128
147
  ` the build anywhere (\`uniweb export\`)? Declare the served URL in site.yml —`,
129
148
  ` the runtime accepts any URL, from any host:`,
package/src/site/index.js CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  export { siteContentPlugin } from './plugin.js'
10
10
  export { defineSiteConfig, readSiteConfig, default } from './config.js'
11
+ // What `uniweb dev` hands the dev server of a site a backend holds (`./preview.js`).
12
+ export { PREVIEW_ENV, encodePreview, readPreview } from './preview.js'
11
13
  export { collectSiteContent } from './content-collector.js'
12
14
  // The `agents:` vocabulary lives in @uniweb/projections (which owns the block);
13
15
  // re-exported so the CLI can validate an author's spelling without taking a
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A PREVIEW FETCHES THE MEDIA ITS BACKEND SERVES — from that backend.
3
+ *
4
+ * A clone keeps a serve URL wherever the backend held one the pull cannot map back to an author's
5
+ * file — a record's image, a param (`uwx/asset-map.js::restoreAssetRefs`: *"the URL that works"*).
6
+ * A root-relative one names a file on the origin that served the document holding it. On the dev
7
+ * server's origin it names nothing, and a dev server answers a path it lacks with `index.html` —
8
+ * so the image broke with a 200.
9
+ *
10
+ * So in a preview (`./preview.js`), a request for media the site does not hold is answered by the
11
+ * backend the site is on. ⭐ Nothing is composed: the path is the URL the content holds, resolved
12
+ * against the origin it came from. ⛔ Only media — a request whose destination is an image, a video,
13
+ * audio, a track or a font — and only what the site does not hold, in `public/` or under its root:
14
+ * the site's own files, its routes and Vite's own paths are never sent anywhere. The request carries
15
+ * no credential; a served asset is public.
16
+ */
17
+ import { existsSync, statSync } from 'node:fs'
18
+ import { join, normalize, sep } from 'node:path'
19
+ import { Readable } from 'node:stream'
20
+
21
+ const MEDIA_DESTINATIONS = new Set(['image', 'video', 'audio', 'track', 'font'])
22
+
23
+ /**
24
+ * @param {{ backend: string, siteRoot: string }} options - the backend's origin (`readPreview`)
25
+ * @returns {import('vite').Plugin}
26
+ */
27
+ export function previewMediaPlugin({ backend, siteRoot }) {
28
+ return {
29
+ name: 'uniweb:preview-media',
30
+ apply: 'serve',
31
+ configureServer(server) {
32
+ // Registered before Vite's own middlewares, whose SPA fallback would answer first.
33
+ server.middlewares.use(async (req, res, next) => {
34
+ const pathname = mediaPath(req)
35
+ if (!pathname) return next()
36
+ const base = server.config.base || '/'
37
+ const local = [pathname]
38
+ if (base !== '/' && pathname.startsWith(base)) local.push('/' + pathname.slice(base.length))
39
+ const roots = [server.config.publicDir, siteRoot].filter(Boolean)
40
+ if (local.some((p) => roots.some((root) => holds(root, p)))) return next()
41
+
42
+ let upstream
43
+ try {
44
+ upstream = await fetch(new URL(req.url, backend), { method: req.method, redirect: 'follow' })
45
+ } catch (err) {
46
+ res.statusCode = 502
47
+ return res.end(`The site's backend (${backend}) did not answer: ${err.message}`)
48
+ }
49
+ // Its answer, whatever it is — a 404 here is the backend not having it either, which
50
+ // is the truth; falling through would answer `index.html`.
51
+ res.statusCode = upstream.status
52
+ for (const header of ['content-type', 'content-length', 'cache-control', 'etag', 'last-modified']) {
53
+ const value = upstream.headers.get(header)
54
+ if (value) res.setHeader(header, value)
55
+ }
56
+ if (req.method === 'HEAD' || !upstream.body) return res.end()
57
+ Readable.fromWeb(upstream.body).pipe(res)
58
+ })
59
+ }
60
+ }
61
+ }
62
+
63
+ /** The root-relative path a media request names, or null for any other request. */
64
+ export function mediaPath(req) {
65
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
66
+ const dest = req.headers['sec-fetch-dest']
67
+ const accept = String(req.headers.accept || '')
68
+ const media = dest ? MEDIA_DESTINATIONS.has(dest) : /^(image|video|audio)\//.test(accept)
69
+ if (!media) return null
70
+ let pathname
71
+ try {
72
+ pathname = decodeURIComponent(new URL(req.url, 'http://preview.invalid').pathname)
73
+ } catch {
74
+ return null
75
+ }
76
+ // Vite's own paths are never the backend's.
77
+ if (/^\/(@|node_modules\/|__)/.test(pathname)) return null
78
+ return pathname
79
+ }
80
+
81
+ /** Whether a file lies at `pathname` under `root`, and inside it. */
82
+ function holds(root, pathname) {
83
+ const file = normalize(join(root, pathname))
84
+ if (file !== root && !file.startsWith(root.endsWith(sep) ? root : root + sep)) return false
85
+ try {
86
+ return existsSync(file) && statSync(file).isFile()
87
+ } catch {
88
+ return false
89
+ }
90
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A PREVIEW OF A SITE A BACKEND HOLDS — what `uniweb dev` read from that backend, handed to the one
3
+ * dev server it starts.
4
+ *
5
+ * A clone names its foundation by catalog ref (`@acme/fnd@1.2.0`) and keeps the media URLs the
6
+ * backend serves, so two things its dev server needs are the backend's to say. `uniweb dev` asks the
7
+ * backend the site is on, through the site, and passes what it learned in `UNIWEB_PREVIEW`, as JSON:
8
+ *
9
+ * { backend: 'https://…', the backend the site is on
10
+ * foundation: { ref, url, cssUrl } } where it serves the version site.yml names
11
+ *
12
+ * ⭐ It resolves the declared foundation; it never substitutes one. The answer names the ref it is
13
+ * for, and `detectFoundationType` honors it for that ref alone — which is what separates it from the
14
+ * `UNIWEB_FOUNDATION_REF` override `config.js` retired, a way for a site to be rendered by code its
15
+ * own `site.yml` did not name.
16
+ *
17
+ * ⭐ And a media URL the clone kept is the backend's own. A pull keeps any serve URL it cannot map
18
+ * back to an author's file (`uwx/asset-map.js::restoreAssetRefs`, *"the URL that works"*), and a
19
+ * root-relative one works on the origin that served the document holding it. So the dev server
20
+ * fetches from `backend` what the content names and the site does not have (`config.js`) — it
21
+ * resolves the URL it was given against the origin it came from, and composes no route.
22
+ *
23
+ * ⛔ Nothing keeps it. A serve location is decided at publish and read from the response that
24
+ * carries it, so it lives as long as the process that asked: `site.yml` keeps the ref, and no push,
25
+ * pull or build sees a URL from here. A build handed one refuses it (`config.js`), so a preview's
26
+ * answer never reaches a built artifact.
27
+ *
28
+ * ⚠️ And it may not be known. Whether a backend tells the CLI where a version is served is the
29
+ * backend's to decide; `uniweb dev` says so and starts nothing when it is not told.
30
+ */
31
+
32
+ /** The variable `uniweb dev` sets for the dev server it starts. */
33
+ export const PREVIEW_ENV = 'UNIWEB_PREVIEW'
34
+
35
+ /**
36
+ * The variable's value.
37
+ *
38
+ * @param {{ backend: string, foundation: { ref: string, url: string, cssUrl?: string|null } }} preview
39
+ * @returns {string}
40
+ */
41
+ export function encodePreview({ backend, foundation }) {
42
+ const { ref, url, cssUrl = null } = foundation
43
+ return JSON.stringify({ backend, foundation: { ref, url, cssUrl: cssUrl || null } })
44
+ }
45
+
46
+ /**
47
+ * What a dev server was handed, or null when it was handed nothing.
48
+ *
49
+ * @param {Record<string, string|undefined>} [env]
50
+ * @returns {{ backend: string, foundation: { ref: string, url: string, cssUrl: string|null } }|null}
51
+ * @throws {Error} when the variable is set and says nothing usable — a preview that cannot read its
52
+ * answer must not start as if it had none
53
+ */
54
+ export function readPreview(env = process.env) {
55
+ const raw = env[PREVIEW_ENV]
56
+ if (!raw) return null
57
+ let given = null
58
+ try {
59
+ given = JSON.parse(raw)
60
+ } catch {
61
+ // reported below
62
+ }
63
+ const f = given?.foundation
64
+ if (!isHttpOrigin(given?.backend) || typeof f?.ref !== 'string' || typeof f.url !== 'string' || !f.url) {
65
+ throw new Error(
66
+ `${PREVIEW_ENV} holds no preview (${JSON.stringify(raw).slice(0, 120)}). ` +
67
+ 'It is set by `uniweb dev`; run that rather than setting it.'
68
+ )
69
+ }
70
+ return {
71
+ backend: new URL(given.backend).origin,
72
+ foundation: { ref: f.ref, url: f.url, cssUrl: typeof f.cssUrl === 'string' ? f.cssUrl : null }
73
+ }
74
+ }
75
+
76
+ // ⛔ `new URL('localhost:8080')` parses — as the scheme `localhost:` — and its origin is the string
77
+ // "null". An origin is http(s), or it is nothing.
78
+ function isHttpOrigin(value) {
79
+ try {
80
+ return /^https?:$/.test(new URL(value).protocol)
81
+ } catch {
82
+ return false
83
+ }
84
+ }
@@ -606,10 +606,12 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
606
606
  * (`@uniweb/schemas/conform`'s `toDeliveredRecord`), with a markdown body in the
607
607
  * schema's content body field — the ProseMirror document for a `format: prosemirror`
608
608
  * field (`article_body.content`, for `@std/article`), the markdown source for a markup
609
- * `text` one. A component is written against one shape, so a static site hands it that
610
- * one (ruled 2026-09-24 [Diego]: the same as hosted). ⛔ Until then a record reached a
611
- * component as its file held it, its body in `content` at the top — a shape no host
612
- * delivers.
609
+ * `text` one. A component is written against one shape, so both lanes deliver the same
610
+ * one — ruled 2026-09-24 [Diego] as a requirement that the shape be one AND a good one,
611
+ * not as an instruction to adopt the host's. ⚠️ It was built by adopting the host's as it
612
+ * was (read until 2026-09-26 as "the same as hosted"), and that shape puts two namespaces
613
+ * in one object (`@uniweb/schemas/conform`, above `toDeliveredRecord`). ⛔ Until 2026-09-24
614
+ * a record reached a component as its file held it, its body in `content` at the top.
613
615
  *
614
616
  * `$name` is the record's handle, taken BEFORE the lift: a brief may declare a `slug`
615
617
  * field of its own (`@std/article`'s does), and the lift puts that one over the file's.
@@ -11,8 +11,9 @@
11
11
  * trip, never a wrong answer: the next push or pull reads it again. That is what makes `.uniweb/`
12
12
  * (gitignored, per copy) the right home for it.
13
13
  *
14
- * The reply is kept whole, as the backend answered it — its `schema` is the foundation schema a
15
- * build would emit, less its data schemas, which travel as Models.
14
+ * Only its `schema` is kept — the foundation schema a build would emit, less its data schemas, which
15
+ * travel as Models. ⛔ The reply carries more than a project reads, a serve location among it; a serve
16
+ * location is decided at publish and read from the response that carries it, never kept here.
16
17
  */
17
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
19
  import { dirname, join } from 'node:path'