@uniweb/build 1.1.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,12 +56,12 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/theming": "~1.1.0",
59
+ "@uniweb/projections": "~1.1.0",
60
+ "@uniweb/content-writer": "~1.0.1",
60
61
  "@uniweb/semantic-parser": "~1.5.1",
61
62
  "@uniweb/schemas": "~1.0.1",
62
- "@uniweb/projections": "~1.1.0",
63
63
  "@uniweb/content-reader": "~1.2.8",
64
- "@uniweb/content-writer": "~1.0.1"
64
+ "@uniweb/theming": "~1.1.0"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "~1.1.0"
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 — `<project>/<name>/dist/` or
58
- * `<project>/extensions/<name>/dist/` — found by the emitter's lookup
59
- * (`resolveExtensionDist`), so the two lanes find the same build.
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
@@ -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
- serveInDev: foundationInfo.type === 'url'
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 built `dist/` behind a site-relative extension URL:
78
- * `/effects/entry.js` → `<project>/effects/dist/` or
79
- * `<project>/extensions/effects/dist/`, with `rest` the path inside it.
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 directory holding the site and its extensions
88
- * @returns {{ distDir: string, urlBase: string, rest: string }|null}
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 parts = url.replace(/^\//, '').split('/')
92
- if (parts.length < 2) return null
93
- const pkgName = parts[0]
94
-
95
- for (const candidate of [
96
- join(projectRoot, pkgName, 'dist'),
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 the
110
- * declarations that could not be resolved — the caller warns about those rather
111
- * than failing, because an absolute-URL extension is legitimately not ours to
112
- * emit and a missing workspace build is a warning the developer can act on.
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 - `site.yml::extensions`, as declared.
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
- if (!Array.isArray(extensions)) return { emit, unresolved }
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` and `node:path`, the same reason `@uniweb/core`'s
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 (existsSync(localPath)) {
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 (existsSync(foundationsPath)) {
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 workspace-local sibling (a directory next to the site, named '${name}')\n` +
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 directory in '../../foundations/${name}'\n` +
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
  }
@@ -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 but no built extension was found for it. ` +
1694
- `The site will 404 on it at runtime — build the extension, or reference it by URL.`
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
+ }