@uniweb/build 0.16.14 → 0.16.16

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.14",
3
+ "version": "0.16.16",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -65,9 +65,9 @@
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/content-reader": "1.2.2",
68
+ "@uniweb/runtime": "0.9.7",
68
69
  "@uniweb/schemas": "0.2.5",
69
- "@uniweb/semantic-parser": "1.2.1",
70
- "@uniweb/runtime": "0.9.6"
70
+ "@uniweb/semantic-parser": "1.2.1"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/prerender.js CHANGED
@@ -17,6 +17,7 @@ import { shouldSplitContent } from './site/split-content.js'
17
17
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
18
18
  import { getAdapter } from './hosts/index.js'
19
19
  import { detectCiContext } from './hosts/detect-ci-context.js'
20
+ import { stripBasePath } from './site/extension-urls.js'
20
21
 
21
22
  /**
22
23
  * Resolve an extension URL to a filesystem path for prerender.
@@ -27,7 +28,14 @@ import { detectCiContext } from './hosts/detect-ci-context.js'
27
28
  * 2. Project root with dist subdir (dev layout, e.g., project/effects/dist/entry.js)
28
29
  * 3. Original URL (absolute or remote — let import() handle it)
29
30
  */
30
- export function resolveExtensionPath(url, distDir, projectRoot) {
31
+ export function resolveExtensionPath(url, distDir, projectRoot, base) {
32
+ // The payload now carries FINAL, base-resolved URLs (see
33
+ // site/extension-urls.js), but `dist/` has no base segment — a site deployed
34
+ // at `/docs/` still writes `dist/effects/entry.js`. Strip the base before
35
+ // mapping onto the build tree. A URL without the base is returned unchanged,
36
+ // so a payload produced before this change still resolves.
37
+ url = stripBasePath(url, base)
38
+
31
39
  // Only resolve URLs that look like root-relative paths
32
40
  if (url.startsWith('/')) {
33
41
  // Try dist directory first (production: files copied to site/dist/)
@@ -638,7 +646,7 @@ export async function prerenderSite(siteDir, options = {}) {
638
646
  for (const ext of extensionSources) {
639
647
  try {
640
648
  const url = typeof ext === 'string' ? ext : ext.url
641
- const extPath = resolveExtensionPath(url, distDir, projectRoot)
649
+ const extPath = resolveExtensionPath(url, distDir, projectRoot, siteContent.config?.base)
642
650
  const extModule = await import(pathToFileURL(extPath).href)
643
651
  loadedExtensions.push(extModule)
644
652
  onProgress(` Extension loaded: ${url}`)
@@ -29,6 +29,7 @@ import {
29
29
  getStructuralWatchPaths
30
30
  } from '../generate-entry.js'
31
31
  import { importMapPlugin } from '../import-map-plugin.js'
32
+ import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
32
33
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
33
34
 
34
35
  /**
@@ -112,22 +113,50 @@ export function detectFoundationType(foundation, siteRoot) {
112
113
  }
113
114
  }
114
115
 
115
- // Catalog registry ref:
116
- // `@org/name@version` → resolves via the registry CDN.
117
- // Link-mode by definition the foundation lives on the hosting edge and is
118
- // loaded at runtime. Surfacing this as `type: 'url'` makes Vite skip the
119
- // local-foundation bundling path and use the noop virtual module. Base URL
120
- // defaults to the production worker but is overridable via UNIWEB_REGISTRY_URL
121
- // 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.
122
136
  const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
123
137
  if (orgScopedMatch) {
124
- const base = process.env.UNIWEB_REGISTRY_URL || 'https://site-router.uniweb-edge.workers.dev'
125
- const [, ns, fn, ver] = orgScopedMatch
126
- return {
127
- type: 'url',
128
- url: `${base}/foundations/${ns}/${fn}/${ver}/foundation.js`,
129
- cssUrl: `${base}/foundations/${ns}/${fn}/${ver}/assets/foundation.css`
130
- }
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
+ )
131
160
  }
132
161
 
133
162
  // Versionless scoped names (`@org/name`) are valid as *handles* — they
@@ -289,15 +318,14 @@ export async function defineSiteConfig(options = {}) {
289
318
  // Read site.yml
290
319
  const siteConfig = readSiteConfig(siteRoot)
291
320
 
292
- // Allow callers to override `foundation:` without modifying site.yml on
293
- // disk. Used by `uniweb deploy` to substitute a workspace-local file: ref
294
- // with the resolved registry ref (`@ns/name@ver`) for the duration of the
295
- // deploy build, so the site builds in runtime/link mode against the just-
296
- // published artifact instead of bundling the local source.
297
- const foundationOverride = process.env.UNIWEB_FOUNDATION_REF
298
- if (foundationOverride) {
299
- siteConfig.foundation = foundationOverride
300
- }
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.
301
329
 
302
330
  // Determine base path for deployment (priority: option > env > site.yml)
303
331
  // Normalize: ensure leading slash, collapse repeated slashes, add trailing slash for Vite
@@ -506,8 +534,14 @@ export async function defineSiteConfig(options = {}) {
506
534
  }
507
535
 
508
536
  // Extension JS modulepreload (CSS left to runtime — we can't reliably
509
- // derive CSS URLs for all extension formats)
510
- const extensions = siteConfig.extensions || []
537
+ // derive CSS URLs for all extension formats).
538
+ //
539
+ // Resolved through the SAME helper the payload uses, so the hint and
540
+ // `config.extensions` cannot disagree. They did: the payload was
541
+ // base-resolved at load time by the runtime while this emitted the
542
+ // raw URL, so on a subdirectory deploy the browser preloaded one URL
543
+ // and then requested another.
544
+ const extensions = resolveExtensionUrls(siteConfig.extensions, base) || []
511
545
  for (const ext of extensions) {
512
546
  const url = typeof ext === 'string' ? ext : ext?.url
513
547
  if (url) {
@@ -525,12 +559,18 @@ export async function defineSiteConfig(options = {}) {
525
559
  })
526
560
  }
527
561
 
528
- // Build foundation config for runtime
529
- const foundationConfig = {
562
+ // Build foundation config for runtime.
563
+ //
564
+ // URLs are resolved against the deployment base HERE, because what reaches
565
+ // the runtime is final — the loader anchors a root-relative URL to the
566
+ // document origin and applies no base of its own. That is what makes the
567
+ // primary foundation and every extension follow one rule; they used to
568
+ // differ. See site/extension-urls.js.
569
+ const foundationConfig = resolveModuleUrl({
530
570
  mode: isRuntimeMode ? 'runtime' : 'bundled',
531
571
  url: foundationInfo.url || '/foundation/foundation.js',
532
572
  cssUrl: foundationInfo.cssUrl || '/foundation/assets/style.css'
533
- }
573
+ }, base)
534
574
 
535
575
  return {
536
576
  // Base public path for deployment (e.g., '/demos/mysite/')
@@ -31,6 +31,7 @@ import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from
31
31
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
32
32
  import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
33
33
  import { parseFetchConfig } from './data-fetcher.js'
34
+ import { resolveExtensionUrls } from './extension-urls.js'
34
35
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
35
36
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
36
37
 
@@ -2175,6 +2176,13 @@ export async function collectSiteContent(sitePath, options = {}) {
2175
2176
  // what that subpath looks like; that is the host's shape, not ours.
2176
2177
  if (base && base !== '/') {
2177
2178
  siteConfig.base = base
2179
+
2180
+ // Extension module URLs are resolved HERE, not at load time. What reaches
2181
+ // the runtime is final — the loader anchors it to the document origin and
2182
+ // applies no base of its own, so the primary foundation and every extension
2183
+ // resolve by one rule. See site/extension-urls.js for why the producer owns
2184
+ // this. Non-URL entries (registry refs) and absolute URLs pass through.
2185
+ siteConfig.extensions = resolveExtensionUrls(siteConfig.extensions, base)
2178
2186
  }
2179
2187
 
2180
2188
  // Profile selects workspace-root defaults: site.yml → pages/ + page mode +
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Base-path resolution for foundation / extension module URLs.
3
+ *
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ * THE RULE: a module URL that reaches the runtime is FINAL.
6
+ * ─────────────────────────────────────────────────────────────────────────────
7
+ *
8
+ * The runtime anchors a root-relative module URL to the document origin (which
9
+ * host serves it) and does nothing else — no base prefixing. So when a site is
10
+ * deployed under a subdirectory, the base must be applied HERE, by the producer
11
+ * that knows it, and identically for the primary foundation and every extension.
12
+ *
13
+ * Why the producer and not the loader:
14
+ *
15
+ * 1. A module URL is a SERVE LOCATION, not a path under the site's mount
16
+ * point. A host may serve a site under one subpath and serve its
17
+ * foundation from an entirely different root. A loader that prefixed every
18
+ * root-relative module URL with the site's base would corrupt exactly that
19
+ * case. Serve locations are read, never constructed.
20
+ *
21
+ * 2. The loader's only available base was `import.meta.env.BASE_URL`, a
22
+ * BUILD-TIME constant of whichever bundle the runtime shipped in. The
23
+ * framework already ruled that the wrong authority for the sibling problem
24
+ * — `setup.js buildDefaultFetcher()` prefers the payload's
25
+ * `content.config.base` precisely because a host-delivered runtime cannot
26
+ * know from a build-time constant what subpath its host serves under.
27
+ *
28
+ * Until 2026-08-04 the base step lived in `loadExtensions()` in the runtime and
29
+ * was applied to extensions but NOT to the primary foundation, so one string
30
+ * resolved to two places depending on which slot it sat in. It was harmless
31
+ * only by coincidence: on a bundled static site BASE_URL *is* the site's base,
32
+ * and on a hosted site it is '/' so the step was inert. Two meanings on one
33
+ * variable, agreeing by luck on the only two lanes that existed.
34
+ *
35
+ * Consumers of the resolved value, which is why this lives in one module:
36
+ * - the payload's `config.extensions` (content-collector) — what the browser loads
37
+ * - the `<link rel=modulepreload>` hints (site/config.js) — must match the
38
+ * payload exactly, or the preload warms a URL the runtime never requests
39
+ * - SSG prerender (prerender.js) maps back to a filesystem path, via
40
+ * `stripBasePath()` below
41
+ */
42
+
43
+ /**
44
+ * Root-relative means "starts at the origin root" — `/foo`. A protocol-relative
45
+ * URL (`//cdn.example.com/foo`) is ABSOLUTE and must never be prefixed; the
46
+ * runtime's own resolver treats it as absolute too.
47
+ */
48
+ function isRootRelative(value) {
49
+ return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//')
50
+ }
51
+
52
+ function joinBase(base, url) {
53
+ const prefix = base.endsWith('/') ? base : `${base}/`
54
+ return prefix + url.slice(1)
55
+ }
56
+
57
+ /**
58
+ * Apply a deployment base to a module source, if it is root-relative.
59
+ *
60
+ * Accepts the same union the runtime's loader does: a URL string, or a
61
+ * `{ url, cssUrl }` object. Both fields are resolved — an explicit
62
+ * root-relative `cssUrl` needs the base exactly as much as `url` does, and
63
+ * missing it produces a silent 404 (the runtime tolerates a failed stylesheet
64
+ * by design, so the only symptom is an unstyled foundation).
65
+ *
66
+ * Anything that is not a root-relative URL — an absolute URL, a
67
+ * protocol-relative URL, a relative path, a registry ref like `@org/name@1.2.3`
68
+ * — passes through untouched.
69
+ *
70
+ * @param {string|Object} source - URL string or {url, cssUrl} object
71
+ * @param {string} [base] - Deployment base path ('/' or absent means no-op)
72
+ * @returns {string|Object} The source with the base applied where applicable
73
+ */
74
+ export function resolveModuleUrl(source, base) {
75
+ if (!base || base === '/') return source
76
+
77
+ if (typeof source === 'string') {
78
+ return isRootRelative(source) ? joinBase(base, source) : source
79
+ }
80
+
81
+ if (source && typeof source === 'object') {
82
+ if (!isRootRelative(source.url) && !isRootRelative(source.cssUrl)) return source
83
+ const out = { ...source }
84
+ if (isRootRelative(out.url)) out.url = joinBase(base, out.url)
85
+ if (isRootRelative(out.cssUrl)) out.cssUrl = joinBase(base, out.cssUrl)
86
+ return out
87
+ }
88
+
89
+ return source
90
+ }
91
+
92
+ /**
93
+ * Map every entry of a site's `extensions:` list through {@link resolveModuleUrl}.
94
+ * Returns the input untouched when there is nothing to do, so a payload built
95
+ * without a base is byte-identical to before.
96
+ *
97
+ * @param {Array<string|Object>} [extensions]
98
+ * @param {string} [base]
99
+ * @returns {Array<string|Object>|undefined}
100
+ */
101
+ export function resolveExtensionUrls(extensions, base) {
102
+ if (!Array.isArray(extensions) || extensions.length === 0) return extensions
103
+ if (!base || base === '/') return extensions
104
+ return extensions.map((entry) => resolveModuleUrl(entry, base))
105
+ }
106
+
107
+ /**
108
+ * Remove a deployment base from a URL, for consumers that need to map a served
109
+ * URL back onto the build tree — SSG prerender resolves `/docs/effects/entry.js`
110
+ * against `dist/`, where the file is at `effects/entry.js`.
111
+ *
112
+ * A URL that does not carry the base is returned unchanged, so this is safe
113
+ * against a payload produced before the base was applied at build time.
114
+ *
115
+ * @param {string} url
116
+ * @param {string} [base]
117
+ * @returns {string}
118
+ */
119
+ export function stripBasePath(url, base) {
120
+ if (!base || base === '/' || typeof url !== 'string') return url
121
+ const prefix = base.endsWith('/') ? base : `${base}/`
122
+ return url.startsWith(prefix) ? `/${url.slice(prefix.length)}` : url
123
+ }