@uniweb/build 1.0.1 → 1.1.1
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 +9 -10
- package/src/hosts/ci-workflow.js +1 -1
- package/src/hosts/cloudflare-pages.js +1 -1
- package/src/hosts/github-pages.js +1 -1
- package/src/hosts/netlify.js +1 -1
- package/src/hosts/vercel.js +1 -1
- package/src/index.js +7 -0
- package/src/prerender.js +10 -3
- package/src/site/config.js +5 -1
- package/src/site/emit-extensions.js +43 -29
- package/src/site/foundation-ref.js +25 -6
- package/src/site/plugin.js +105 -6
- package/src/site/workspace-extension.js +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"./import-map-plugin": "./src/import-map-plugin.js",
|
|
24
24
|
"./emit-surface": "./src/uwx/emit-surface.json"
|
|
25
25
|
},
|
|
26
|
-
"bin": {},
|
|
27
26
|
"files": [
|
|
28
27
|
"src"
|
|
29
28
|
],
|
|
@@ -46,7 +45,7 @@
|
|
|
46
45
|
"url": "https://github.com/uniweb/build/issues"
|
|
47
46
|
},
|
|
48
47
|
"engines": {
|
|
49
|
-
"node": ">=
|
|
48
|
+
"node": ">=22.12"
|
|
50
49
|
},
|
|
51
50
|
"devDependencies": {
|
|
52
51
|
"vitest": "^4.1.7"
|
|
@@ -57,15 +56,15 @@
|
|
|
57
56
|
"js-yaml": "^4.1.0",
|
|
58
57
|
"sharp": "^0.35.3",
|
|
59
58
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/
|
|
59
|
+
"@uniweb/projections": "~1.1.0",
|
|
61
60
|
"@uniweb/content-writer": "~1.0.1",
|
|
62
61
|
"@uniweb/semantic-parser": "~1.5.1",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/schemas": "~1.0.1",
|
|
63
|
+
"@uniweb/content-reader": "~1.2.8",
|
|
64
|
+
"@uniweb/theming": "~1.1.0"
|
|
66
65
|
},
|
|
67
66
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "~1.0
|
|
67
|
+
"@uniweb/runtime": "~1.1.0"
|
|
69
68
|
},
|
|
70
69
|
"peerDependencies": {
|
|
71
70
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +73,8 @@
|
|
|
74
73
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
74
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
75
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
76
|
+
"@uniweb/core": "~1.1.0",
|
|
77
|
+
"@uniweb/runtime": "~1.1.0"
|
|
79
78
|
},
|
|
80
79
|
"peerDependenciesMeta": {
|
|
81
80
|
"vite": {
|
package/src/hosts/ci-workflow.js
CHANGED
|
@@ -207,7 +207,7 @@ async function deploy({ distDir, deployConfig = {}, env = process.env, log = ()
|
|
|
207
207
|
async function initCi({
|
|
208
208
|
site,
|
|
209
209
|
packageManager = 'pnpm',
|
|
210
|
-
nodeVersion = '
|
|
210
|
+
nodeVersion = '22',
|
|
211
211
|
pnpmVersion,
|
|
212
212
|
domain = null,
|
|
213
213
|
projectName = null,
|
package/src/hosts/netlify.js
CHANGED
package/src/hosts/vercel.js
CHANGED
package/src/index.js
CHANGED
|
@@ -73,6 +73,13 @@ export {
|
|
|
73
73
|
isExtensionUrl,
|
|
74
74
|
isSiteRelativeExtensionUrl
|
|
75
75
|
} from './uwx/site.js'
|
|
76
|
+
// Where a site finds a workspace extension — one rule, which the build, prerender
|
|
77
|
+
// and the dev server follow, for the CLI to ask rather than restate.
|
|
78
|
+
export {
|
|
79
|
+
extensionCandidates,
|
|
80
|
+
extensionNameOf,
|
|
81
|
+
findSiteExtension
|
|
82
|
+
} from './site/workspace-extension.js'
|
|
76
83
|
|
|
77
84
|
// Foundation source root resolution (reads package.json::main)
|
|
78
85
|
export { resolveFoundationSrcDir, resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
package/src/prerender.js
CHANGED
|
@@ -54,13 +54,20 @@ import { isSiteRelative, resolveExtensionDist } from './site/emit-extensions.js'
|
|
|
54
54
|
* sections belonged until the page ran in the browser.
|
|
55
55
|
*
|
|
56
56
|
* Resolution order:
|
|
57
|
-
* 1. The extension's own build —
|
|
58
|
-
* `<
|
|
59
|
-
*
|
|
57
|
+
* 1. The extension's own build — the `dist/` of the workspace extension with the
|
|
58
|
+
* URL's name, `<name>/` or `extensions/<name>/` in the folder the site is in or
|
|
59
|
+
* a folder above it, up to the workspace root (site/workspace-extension.js) —
|
|
60
|
+
* found by the emitter's lookup (`resolveExtensionDist`), so the two lanes find
|
|
61
|
+
* the same build.
|
|
60
62
|
* 2. A file in the site's dist, for an extension no workspace package builds (a
|
|
61
63
|
* pre-built module placed in `public/`). Its bare imports resolve only where
|
|
62
64
|
* the site's own dependencies carry them.
|
|
63
65
|
* 3. Original URL (absolute or remote — let import() handle it)
|
|
66
|
+
*
|
|
67
|
+
* @param {string} url - as the payload carries it
|
|
68
|
+
* @param {string} distDir - the site's `dist/`
|
|
69
|
+
* @param {string} projectRoot - the folder the site is in, where the search starts
|
|
70
|
+
* @param {string} [base] - the site's deployment base
|
|
64
71
|
*/
|
|
65
72
|
export function resolveExtensionPath(url, distDir, projectRoot, base) {
|
|
66
73
|
// The payload now carries FINAL, base-resolved URLs (see
|
package/src/site/config.js
CHANGED
|
@@ -421,7 +421,11 @@ export async function defineSiteConfig(options = {}) {
|
|
|
421
421
|
// nothing else resolves them: the foundation dev plugin rewrites a LOCAL
|
|
422
422
|
// foundation's imports, and a served one is not ours to rewrite. So the dev server
|
|
423
423
|
// answers the import map a build writes, bridging to the site's own copies.
|
|
424
|
-
|
|
424
|
+
// ⭐ An extension is always loaded from a URL — a workspace one, served by the
|
|
425
|
+
// site plugin from its build, as much as a remote one — so a site with any
|
|
426
|
+
// extension gets the same bridge. ⛔ Until 2026-10-13 only a URL foundation did,
|
|
427
|
+
// and in dev an extension's `import 'react'` had nothing to resolve against.
|
|
428
|
+
serveInDev: foundationInfo.type === 'url' || hasExtensions
|
|
425
429
|
}))
|
|
426
430
|
}
|
|
427
431
|
|
|
@@ -44,10 +44,17 @@
|
|
|
44
44
|
* the extension from its own build (`resolveExtensionPath`), because the copy
|
|
45
45
|
* sits under the site package, where the extension's bare imports
|
|
46
46
|
* (`@uniweb/core`, `react`) do not resolve.
|
|
47
|
+
*
|
|
48
|
+
* ── Which folder is the extension ──
|
|
49
|
+
*
|
|
50
|
+
* Not decided here: `./workspace-extension.js` holds the rule, and the dev
|
|
51
|
+
* server, prerender and the CLI ask it too.
|
|
47
52
|
*/
|
|
48
53
|
|
|
49
54
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
50
55
|
import { join, relative, resolve } from 'node:path'
|
|
56
|
+
import { extensionNameOf, findExtensionPackage } from './workspace-extension.js'
|
|
57
|
+
import { stripBasePath } from './extension-urls.js'
|
|
51
58
|
|
|
52
59
|
/** True for the `/effects/entry.js` form — the only one this site can serve. */
|
|
53
60
|
export function isSiteRelative(decl) {
|
|
@@ -64,8 +71,8 @@ function walk(dir, base = dir) {
|
|
|
64
71
|
})
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
/** Is this file part of what a browser fetches? */
|
|
68
|
-
function isBrowserAsset(rel) {
|
|
74
|
+
/** Is this file part of what a browser fetches? The dev server serves the same set. */
|
|
75
|
+
export function isBrowserAsset(rel) {
|
|
69
76
|
if (rel.endsWith('.map')) return false
|
|
70
77
|
if (rel === 'runtime-pin.json') return false
|
|
71
78
|
if (rel.startsWith('meta/') || rel.startsWith(`meta\\`)) return false
|
|
@@ -74,9 +81,9 @@ function isBrowserAsset(rel) {
|
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
/**
|
|
77
|
-
* Locate the
|
|
78
|
-
* `/effects/entry.js` →
|
|
79
|
-
*
|
|
84
|
+
* Locate the workspace extension behind a site-relative extension URL —
|
|
85
|
+
* `/effects/entry.js` → the folder `./workspace-extension.js` finds for
|
|
86
|
+
* `effects` — with `distDir` its build and `rest` the path inside it.
|
|
80
87
|
*
|
|
81
88
|
* One lookup for both lanes that need the extension's build: this emitter, which
|
|
82
89
|
* copies it into the site's output, and prerender, which imports it from where it
|
|
@@ -84,50 +91,57 @@ function isBrowserAsset(rel) {
|
|
|
84
91
|
* cannot, by construction rather than by two lists kept in step by hand.
|
|
85
92
|
*
|
|
86
93
|
* @param {string} url - a site-relative URL, with any deployment base removed
|
|
87
|
-
* @param {string} projectRoot - the
|
|
88
|
-
*
|
|
94
|
+
* @param {string} projectRoot - the folder the site is in; the search starts
|
|
95
|
+
* there and climbs to the workspace root
|
|
96
|
+
* @returns {{ distDir: string, urlBase: string, rest: string, packageDir: string, built: boolean }|null}
|
|
97
|
+
* null when no workspace extension has that name; `built: false` when one does
|
|
98
|
+
* and has no `dist/` yet
|
|
89
99
|
*/
|
|
90
100
|
export function resolveExtensionDist(url, projectRoot) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
join(projectRoot, 'extensions', pkgName, 'dist')
|
|
98
|
-
]) {
|
|
99
|
-
if (existsSync(candidate)) {
|
|
100
|
-
return { distDir: candidate, urlBase: pkgName, rest: parts.slice(1).join('/') }
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return null
|
|
101
|
+
const named = extensionNameOf(url)
|
|
102
|
+
if (!named) return null
|
|
103
|
+
const packageDir = findExtensionPackage(named.name, projectRoot)
|
|
104
|
+
if (!packageDir) return null
|
|
105
|
+
const distDir = join(packageDir, 'dist')
|
|
106
|
+
return { distDir, urlBase: named.name, rest: named.rest, packageDir, built: existsSync(distDir) }
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
/**
|
|
107
110
|
* Files to emit for a site's declared extensions.
|
|
108
111
|
*
|
|
109
|
-
* Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
112
|
+
* Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus what could
|
|
113
|
+
* not be emitted, for the caller to warn about rather than fail on: an
|
|
114
|
+
* absolute-URL extension is legitimately not ours to emit, and a missing or
|
|
115
|
+
* unbuilt workspace extension is something the developer can act on.
|
|
113
116
|
*
|
|
114
|
-
* @param {Array} extensions - `
|
|
117
|
+
* @param {Array} extensions - the payload's `config.extensions`.
|
|
115
118
|
* @param {string} siteDir - the site package directory.
|
|
119
|
+
* @param {{ base?: string }} [options] - `base`: the deployment base the payload's
|
|
120
|
+
* URLs carry. `dist/` has no base segment, so it is removed before the URL names
|
|
121
|
+
* a folder — with `/docs/` left on, `/docs/effects/entry.js` named an extension
|
|
122
|
+
* `docs`, and a site deployed under a subpath emitted none.
|
|
123
|
+
* @returns {{ emit: Array<{ fileName: string, source: Buffer }>, unresolved: string[],
|
|
124
|
+
* unbuilt: Array<{ url: string, dir: string }> }}
|
|
116
125
|
*/
|
|
117
|
-
export function collectExtensionAssets(extensions, siteDir) {
|
|
126
|
+
export function collectExtensionAssets(extensions, siteDir, { base } = {}) {
|
|
118
127
|
const emit = []
|
|
119
128
|
const unresolved = []
|
|
120
|
-
|
|
129
|
+
const unbuilt = []
|
|
130
|
+
if (!Array.isArray(extensions)) return { emit, unresolved, unbuilt }
|
|
121
131
|
const projectRoot = resolve(siteDir, '..')
|
|
122
132
|
|
|
123
133
|
for (const decl of extensions) {
|
|
124
134
|
if (!isSiteRelative(decl)) continue // absolute URL or a ref — someone else serves it
|
|
125
135
|
const url = typeof decl === 'string' ? decl : decl.url
|
|
126
|
-
const found = resolveExtensionDist(url, projectRoot)
|
|
136
|
+
const found = resolveExtensionDist(stripBasePath(url, base), projectRoot)
|
|
127
137
|
if (!found) {
|
|
128
138
|
unresolved.push(url)
|
|
129
139
|
continue
|
|
130
140
|
}
|
|
141
|
+
if (!found.built) {
|
|
142
|
+
unbuilt.push({ url, dir: found.packageDir })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
131
145
|
for (const rel of walk(found.distDir)) {
|
|
132
146
|
if (!isBrowserAsset(rel)) continue
|
|
133
147
|
emit.push({
|
|
@@ -136,5 +150,5 @@ export function collectExtensionAssets(extensions, siteDir) {
|
|
|
136
150
|
})
|
|
137
151
|
}
|
|
138
152
|
}
|
|
139
|
-
return { emit, unresolved }
|
|
153
|
+
return { emit, unresolved, unbuilt }
|
|
140
154
|
}
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Three implementations of one question, none of which agreed, and the cause was
|
|
15
15
|
* a pure function sitting behind a bundler import. So it moved here, with no
|
|
16
|
-
* dependency beyond `node:fs
|
|
17
|
-
* `route-match` and `data-paths` are leaves.
|
|
16
|
+
* dependency beyond `node:fs`, `node:path` and the leaf package classifier, the
|
|
17
|
+
* same reason `@uniweb/core`'s `route-match` and `data-paths` are leaves.
|
|
18
18
|
*
|
|
19
19
|
* ## What it answers, and what it refuses
|
|
20
20
|
*
|
|
@@ -30,6 +30,21 @@
|
|
|
30
30
|
|
|
31
31
|
import { existsSync, readFileSync } from 'node:fs'
|
|
32
32
|
import { resolve, join } from 'node:path'
|
|
33
|
+
import { classifyPackage } from '../utils/classify-package.js'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A folder found by NAME rather than named outright — a sibling of the site, or
|
|
37
|
+
* `foundations/<name>` — is the foundation only if it is one. A `file:` dep names
|
|
38
|
+
* its folder outright and is taken as written.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ Until 2026-10-13 any folder of the right name passed. In a workspace holding
|
|
41
|
+
* `foundations/blog` and `sites/blog`, the site `sites/blog` resolved its
|
|
42
|
+
* foundation to ITSELF, and `sites/main` resolved it to the other site — while
|
|
43
|
+
* each site's `file:` dep named `foundations/blog` correctly, one check too late.
|
|
44
|
+
*/
|
|
45
|
+
function isFoundationDir(dir) {
|
|
46
|
+
return existsSync(dir) && classifyPackage(dir) === 'foundation'
|
|
47
|
+
}
|
|
33
48
|
|
|
34
49
|
/**
|
|
35
50
|
* A catalog ref — `@org/name@version` — in its parts, or null for anything else.
|
|
@@ -174,7 +189,7 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
|
|
|
174
189
|
|
|
175
190
|
// Check if it's a local workspace sibling (directory name matches package name)
|
|
176
191
|
const localPath = resolve(siteRoot, '..', name)
|
|
177
|
-
if (
|
|
192
|
+
if (isFoundationDir(localPath)) {
|
|
178
193
|
return {
|
|
179
194
|
type: 'local',
|
|
180
195
|
name,
|
|
@@ -201,7 +216,7 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
|
|
|
201
216
|
|
|
202
217
|
// Check in foundations/ directory (for multi-site projects)
|
|
203
218
|
const foundationsPath = resolve(siteRoot, '..', '..', 'foundations', name)
|
|
204
|
-
if (
|
|
219
|
+
if (isFoundationDir(foundationsPath)) {
|
|
205
220
|
return {
|
|
206
221
|
type: 'local',
|
|
207
222
|
name,
|
|
@@ -226,14 +241,18 @@ export function detectFoundationType(foundation, siteRoot, { served = null } = {
|
|
|
226
241
|
// a registry ref, or a URL — none of the supported shapes. Fail with
|
|
227
242
|
// guidance rather than fall through to a node_modules lookup that will
|
|
228
243
|
// produce a confusing error later in the build.
|
|
244
|
+
const notFoundations = [localPath, foundationsPath].filter((dir) => existsSync(dir))
|
|
229
245
|
throw new Error(
|
|
230
246
|
`site.yml foundation: '${name}' did not resolve.\n` +
|
|
231
247
|
`Foundations must be one of:\n` +
|
|
232
|
-
` - a
|
|
248
|
+
` - a foundation next to the site, in a folder named '${name}'\n` +
|
|
233
249
|
` - a 'file:' dep in the site's package.json\n` +
|
|
234
|
-
` - a
|
|
250
|
+
` - a foundation in '../../foundations/${name}'\n` +
|
|
235
251
|
` - a versioned registry ref: '@org/${name}@<version>'\n` +
|
|
236
252
|
` - a full URL: 'https://...'\n` +
|
|
253
|
+
(notFoundations.length
|
|
254
|
+
? `${notFoundations.join(' and ')} ${notFoundations.length > 1 ? 'exist' : 'exists'}, but not as a foundation package.\n`
|
|
255
|
+
: '') +
|
|
237
256
|
`Foundations are runtime federated modules, not npm packages — there is no fall-through to node_modules.`
|
|
238
257
|
)
|
|
239
258
|
}
|
package/src/site/plugin.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* })
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import { resolve, join } from 'node:path'
|
|
33
|
+
import { resolve, join, relative, sep } from 'node:path'
|
|
34
34
|
import { watch, existsSync } from 'node:fs'
|
|
35
35
|
import { readFile, readdir } from 'node:fs/promises'
|
|
36
36
|
import { resolveDefaultLocale, resolveFetchConfigs, DATA_DIR } from '@uniweb/core'
|
|
@@ -54,13 +54,38 @@ import { RECORDS_DIR, resolveRecordsDir } from './entity-pool.js'
|
|
|
54
54
|
import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
|
|
55
55
|
import { shouldSplitContent } from './split-content.js'
|
|
56
56
|
import { FONT_LINKS_MARKER } from './head-markers.js'
|
|
57
|
-
import { collectExtensionAssets } from './emit-extensions.js'
|
|
57
|
+
import { collectExtensionAssets, isBrowserAsset } from './emit-extensions.js'
|
|
58
|
+
import { extensionNameOf, findSiteExtension } from './workspace-extension.js'
|
|
58
59
|
|
|
59
60
|
// BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
|
|
60
61
|
const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
|
|
61
62
|
|
|
62
63
|
const FAVICON_AUTODETECT = ['favicon.svg', 'favicon.ico', 'favicon.png']
|
|
63
64
|
|
|
65
|
+
const EXTENSION_CONTENT_TYPES = {
|
|
66
|
+
js: 'text/javascript',
|
|
67
|
+
mjs: 'text/javascript',
|
|
68
|
+
css: 'text/css',
|
|
69
|
+
json: 'application/json',
|
|
70
|
+
svg: 'image/svg+xml',
|
|
71
|
+
png: 'image/png',
|
|
72
|
+
jpg: 'image/jpeg',
|
|
73
|
+
jpeg: 'image/jpeg',
|
|
74
|
+
gif: 'image/gif',
|
|
75
|
+
webp: 'image/webp',
|
|
76
|
+
avif: 'image/avif',
|
|
77
|
+
woff: 'font/woff',
|
|
78
|
+
woff2: 'font/woff2',
|
|
79
|
+
ttf: 'font/ttf',
|
|
80
|
+
otf: 'font/otf',
|
|
81
|
+
wasm: 'application/wasm'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The Content-Type the dev server answers an extension's built file with. */
|
|
85
|
+
function extensionContentType(file) {
|
|
86
|
+
return EXTENSION_CONTENT_TYPES[file.split('.').pop()?.toLowerCase()] || 'application/octet-stream'
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
function faviconTypeFor(href) {
|
|
65
90
|
const ext = href.split('?')[0].split('.').pop()?.toLowerCase()
|
|
66
91
|
if (ext === 'svg') return 'image/svg+xml'
|
|
@@ -1022,6 +1047,71 @@ export function siteContentPlugin(options = {}) {
|
|
|
1022
1047
|
console.error(`[dev-api] ${err.message}`)
|
|
1023
1048
|
}
|
|
1024
1049
|
|
|
1050
|
+
// A site-relative workspace extension (`/effects/entry.js`) is served from
|
|
1051
|
+
// the site's own origin: the build copies its built files there
|
|
1052
|
+
// (`collectExtensionAssets`, in generateBundle). Dev serves the same set,
|
|
1053
|
+
// from the same folder, found by the same rule (`./workspace-extension.js`),
|
|
1054
|
+
// so `uniweb dev` and `uniweb build` cannot disagree about whether a site
|
|
1055
|
+
// has its extension. Its bare imports (`react`, `@uniweb/core`) resolve
|
|
1056
|
+
// through the import map a dev page with extensions carries (`./config.js`,
|
|
1057
|
+
// `serveInDev`). ⚠️ Synchronous, like the mount above, so it runs before
|
|
1058
|
+
// Vite's SPA fallback.
|
|
1059
|
+
//
|
|
1060
|
+
// ⛔ Until 2026-10-13 nothing served it in dev: the request reached the SPA
|
|
1061
|
+
// fallback, which answered `index.html` with a 200, `import()` failed on
|
|
1062
|
+
// it, and the runtime dropped the extension. Measured on the `extensions`
|
|
1063
|
+
// template.
|
|
1064
|
+
const warnedExtensions = new Set()
|
|
1065
|
+
devServer.middlewares.use(async (req, res, next) => {
|
|
1066
|
+
const declared = siteContent?.config?.extensions
|
|
1067
|
+
const named = Array.isArray(declared) && declared.length ? extensionNameOf(req.url, basePath) : null
|
|
1068
|
+
const entry =
|
|
1069
|
+
named &&
|
|
1070
|
+
declared
|
|
1071
|
+
.map((d) => (typeof d === 'string' ? d : d?.url))
|
|
1072
|
+
.find((url) => extensionNameOf(url, basePath)?.name === named.name)
|
|
1073
|
+
if (!entry || !named.rest) return next()
|
|
1074
|
+
|
|
1075
|
+
const packageDir = findSiteExtension(named.name, resolvedSitePath)
|
|
1076
|
+
if (packageDir) {
|
|
1077
|
+
const distDir = join(packageDir, 'dist')
|
|
1078
|
+
let rest = named.rest
|
|
1079
|
+
try {
|
|
1080
|
+
rest = decodeURIComponent(rest)
|
|
1081
|
+
} catch {}
|
|
1082
|
+
const file = resolve(distDir, rest)
|
|
1083
|
+
if (file.startsWith(distDir + sep) && isBrowserAsset(rest) && existsSync(file)) {
|
|
1084
|
+
try {
|
|
1085
|
+
const body = await readFile(file)
|
|
1086
|
+
res.setHeader('Content-Type', extensionContentType(file))
|
|
1087
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
1088
|
+
res.end(body)
|
|
1089
|
+
return
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
return next(err)
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Only the declared entry is answered when its file is missing: the runtime
|
|
1097
|
+
// drops an extension it cannot load either way, and a 404 says what the
|
|
1098
|
+
// SPA's HTML would hide. Any other path under that name is left to Vite.
|
|
1099
|
+
if (extensionNameOf(entry, basePath)?.rest !== named.rest) return next()
|
|
1100
|
+
if (!warnedExtensions.has(entry)) {
|
|
1101
|
+
warnedExtensions.add(entry)
|
|
1102
|
+
console.warn(
|
|
1103
|
+
packageDir
|
|
1104
|
+
? `[site-content] Extension '${entry}' is not built (${relative(resolvedSitePath, packageDir)}). ` +
|
|
1105
|
+
`Build it — \`uniweb build\` from the workspace root — and reload.`
|
|
1106
|
+
: `[site-content] Extension '${entry}' is site-relative, and no workspace extension has its name. ` +
|
|
1107
|
+
`A site finds one in <name>/ or extensions/<name>/, in the folder the site is in or a folder ` +
|
|
1108
|
+
`above it, up to the workspace root.`
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
res.statusCode = 404
|
|
1112
|
+
res.end()
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1025
1115
|
// Watch for content changes in dev mode
|
|
1026
1116
|
if (shouldWatch) {
|
|
1027
1117
|
// Debounce rebuilds. One that `queries.yml` or `site.yml` asked for also
|
|
@@ -1681,17 +1771,26 @@ export function siteContentPlugin(options = {}) {
|
|
|
1681
1771
|
// Without this the build succeeds, prerender renders the extension's
|
|
1682
1772
|
// sections from the workspace, and the browser then 404s and replaces
|
|
1683
1773
|
// them with `Component not found` on hydration.
|
|
1684
|
-
const { emit, unresolved } = collectExtensionAssets(
|
|
1774
|
+
const { emit, unresolved, unbuilt } = collectExtensionAssets(
|
|
1685
1775
|
finalContent.config?.extensions,
|
|
1686
|
-
resolve(sitePath)
|
|
1776
|
+
resolve(sitePath),
|
|
1777
|
+
{ base: finalContent.config?.base }
|
|
1687
1778
|
)
|
|
1688
1779
|
for (const asset of emit) {
|
|
1689
1780
|
this.emitFile({ type: 'asset', fileName: asset.fileName, source: asset.source })
|
|
1690
1781
|
}
|
|
1691
1782
|
for (const url of unresolved) {
|
|
1692
1783
|
this.warn(
|
|
1693
|
-
`Extension '${url}' is site-relative
|
|
1694
|
-
`
|
|
1784
|
+
`Extension '${url}' is site-relative, and no workspace extension has its name. ` +
|
|
1785
|
+
`A site finds one in <name>/ or extensions/<name>/, in the folder the site is in or a folder ` +
|
|
1786
|
+
`above it, up to the workspace root. The site will 404 on it at runtime — move the extension ` +
|
|
1787
|
+
`there, or reference it by URL.`
|
|
1788
|
+
)
|
|
1789
|
+
}
|
|
1790
|
+
for (const { url, dir } of unbuilt) {
|
|
1791
|
+
this.warn(
|
|
1792
|
+
`Extension '${url}' is not built (${relative(resolve(sitePath), dir) || dir}), so the site will ` +
|
|
1793
|
+
`404 on it at runtime. Run \`uniweb build\` from the workspace root, which builds extensions first.`
|
|
1695
1794
|
)
|
|
1696
1795
|
}
|
|
1697
1796
|
},
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a site finds a WORKSPACE extension — the one rule, asked by every lane.
|
|
3
|
+
*
|
|
4
|
+
* A site declares a workspace extension by the URL it serves it at
|
|
5
|
+
* (`extensions: ['/effects/entry.js']`) or by name (`extensions: [effects]`).
|
|
6
|
+
* Either way the extension's name is `effects`, and this module answers which
|
|
7
|
+
* folder of the workspace holds it. Each lane that needs that answer asks here:
|
|
8
|
+
* the site build, which copies the extension's build into the site's output
|
|
9
|
+
* (`emit-extensions.js`); prerender, which imports it (`../prerender.js`); the
|
|
10
|
+
* dev server, which serves it (`plugin.js`); and the CLI — `add extension`
|
|
11
|
+
* deciding whether a site can load what it just made, `doctor`, and `push` /
|
|
12
|
+
* `publish` bringing a named extension along.
|
|
13
|
+
*
|
|
14
|
+
* ── The rule ──
|
|
15
|
+
*
|
|
16
|
+
* The extension named `<name>` is the nearest `<name>/` or `extensions/<name>/`
|
|
17
|
+
* that is a foundation package — looking first in the folder the site is in,
|
|
18
|
+
* then in each folder above it, up to the workspace root:
|
|
19
|
+
*
|
|
20
|
+
* project/ ← the workspace root (pnpm-workspace.yaml)
|
|
21
|
+
* ├── extensions/effects/ ← what sites/main loads as /effects/entry.js
|
|
22
|
+
* ├── sites/main/
|
|
23
|
+
* └── docs/
|
|
24
|
+
* ├── effects/ ← what docs/site loads: it is nearer
|
|
25
|
+
* └── site/
|
|
26
|
+
*
|
|
27
|
+
* ── Why each part ──
|
|
28
|
+
*
|
|
29
|
+
* - **Nearest first**, so a co-located project can keep its own extension under
|
|
30
|
+
* a name a shared one also has — the way a nearer `node_modules` wins.
|
|
31
|
+
* - **Up to the workspace root and no further** — the folder holding
|
|
32
|
+
* `pnpm-workspace.yaml`, or a `package.json` that lists `workspaces`, the same
|
|
33
|
+
* marker the CLI finds a workspace by. A folder outside the project is never
|
|
34
|
+
* part of it, and a site in no workspace looks only in the folder it is in.
|
|
35
|
+
* - **A foundation package, not any folder of that name** (`classifyPackage`):
|
|
36
|
+
* an extension is a foundation. A built SITE named `effects` has a `dist/` as
|
|
37
|
+
* well, and the more folders the search visits, the likelier a same-named one
|
|
38
|
+
* that is not the extension.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ Until 2026-10-13 the build looked only in the folder the site is in, and
|
|
41
|
+
* `add extension` judged an extension reachable from the workspace root — so a
|
|
42
|
+
* site at `sites/main` was wired to `extensions/effects`, which its build then
|
|
43
|
+
* could not find. The rule was written three times — this lookup, `add`'s check
|
|
44
|
+
* and `doctor`'s parsing of the URL — and they disagreed. It is written once now.
|
|
45
|
+
*
|
|
46
|
+
* Node built-ins only, beside the leaf classifier, so any lane can import it
|
|
47
|
+
* without pulling in Vite.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
51
|
+
import { dirname, join, resolve } from 'node:path'
|
|
52
|
+
import { classifyPackage } from '../utils/classify-package.js'
|
|
53
|
+
import { stripBasePath } from './extension-urls.js'
|
|
54
|
+
|
|
55
|
+
/** A folder holding a workspace manifest — what the CLI finds a workspace by. */
|
|
56
|
+
function isWorkspaceRoot(dir) {
|
|
57
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true
|
|
58
|
+
const pkgPath = join(dir, 'package.json')
|
|
59
|
+
if (!existsSync(pkgPath)) return false
|
|
60
|
+
try {
|
|
61
|
+
return Array.isArray(JSON.parse(readFileSync(pkgPath, 'utf8')).workspaces)
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One folder name — never a path, so a declaration cannot walk the search out of a folder. */
|
|
68
|
+
function isExtensionName(name) {
|
|
69
|
+
return typeof name === 'string' && name !== '' && name !== '.' && name !== '..' && !/[\\/]/.test(name)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The folders a site searches for a workspace extension, nearest first.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} fromDir - the folder the site is in
|
|
76
|
+
* @returns {string[]} `fromDir`, then each folder above it up to and including
|
|
77
|
+
* the workspace root — or `fromDir` alone when no workspace holds it
|
|
78
|
+
*/
|
|
79
|
+
export function extensionSearchDirs(fromDir) {
|
|
80
|
+
const start = resolve(fromDir)
|
|
81
|
+
const dirs = []
|
|
82
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
83
|
+
dirs.push(dir)
|
|
84
|
+
if (isWorkspaceRoot(dir)) return dirs
|
|
85
|
+
if (dirname(dir) === dir) return [start]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every folder that could hold the extension named `name`, in the order they are
|
|
91
|
+
* tried — for a message that says where the build looked.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} name - the extension's name: its folder, and its URL's first segment
|
|
94
|
+
* @param {string} fromDir - the folder the site is in
|
|
95
|
+
* @returns {string[]}
|
|
96
|
+
*/
|
|
97
|
+
export function extensionCandidates(name, fromDir) {
|
|
98
|
+
if (!isExtensionName(name)) return []
|
|
99
|
+
return extensionSearchDirs(fromDir).flatMap((dir) => [join(dir, name), join(dir, 'extensions', name)])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The folder of the workspace extension named `name`, or null.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} name
|
|
106
|
+
* @param {string} fromDir - the folder the site is in
|
|
107
|
+
* @returns {string|null} an absolute package folder; built or not — the caller
|
|
108
|
+
* that needs its `dist/` checks for it, so "not built" is never reported as
|
|
109
|
+
* "not found"
|
|
110
|
+
*/
|
|
111
|
+
export function findExtensionPackage(name, fromDir) {
|
|
112
|
+
for (const candidate of extensionCandidates(name, fromDir)) {
|
|
113
|
+
if (existsSync(candidate) && classifyPackage(candidate) === 'foundation') return candidate
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* {@link findExtensionPackage} for a site named by its own folder.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @param {string} siteDir - the site's folder
|
|
123
|
+
* @returns {string|null}
|
|
124
|
+
*/
|
|
125
|
+
export function findSiteExtension(name, siteDir) {
|
|
126
|
+
return findExtensionPackage(name, dirname(resolve(siteDir)))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The extension name a site-relative URL refers to: its first segment, once the
|
|
131
|
+
* deployment base is removed (`/docs/effects/entry.js` at base `/docs/` → `effects`).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} url - a site-relative URL, as declared or as the payload carries it
|
|
134
|
+
* @param {string} [base] - the site's deployment base
|
|
135
|
+
* @returns {{ name: string, rest: string } | null} null when the URL names no file inside a folder
|
|
136
|
+
*/
|
|
137
|
+
export function extensionNameOf(url, base) {
|
|
138
|
+
if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//')) return null
|
|
139
|
+
const parts = stripBasePath(url, base).replace(/^\//, '').split(/[?#]/)[0].split('/')
|
|
140
|
+
if (parts.length < 2 || !isExtensionName(parts[0])) return null
|
|
141
|
+
return { name: parts[0], rest: parts.slice(1).join('/') }
|
|
142
|
+
}
|