@uniweb/build 0.30.1 → 0.31.0
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 +8 -8
- package/src/dev/api-mount.js +142 -0
- package/src/site/config.js +45 -2
- package/src/site/plugin.js +13 -0
- package/src/utils/foundation-resolution-check.js +142 -0
- package/src/uwx/data-schema.js +35 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
+
"@uniweb/schemas": "^0.2.13",
|
|
63
|
+
"@uniweb/theming": "^0.1.15",
|
|
64
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
62
65
|
"@uniweb/content-reader": "^1.2.4",
|
|
63
|
-
"@uniweb/projections": "^0.5.
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/schemas": "^0.2.11",
|
|
66
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
67
|
-
"@uniweb/theming": "^0.1.15"
|
|
66
|
+
"@uniweb/projections": "^0.5.3",
|
|
67
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
68
68
|
},
|
|
69
69
|
"optionalDependencies": {
|
|
70
|
-
"@uniweb/runtime": "^0.13.
|
|
70
|
+
"@uniweb/runtime": "^0.13.4"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@tailwindcss/vite": "^4.0.0",
|
|
77
77
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
78
78
|
"vite-plugin-svgr": "^4.0.0",
|
|
79
|
-
"@uniweb/core": "^0.
|
|
79
|
+
"@uniweb/core": "^0.15.0"
|
|
80
80
|
},
|
|
81
81
|
"peerDependenciesMeta": {
|
|
82
82
|
"vite": {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve, join } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import yaml from 'js-yaml'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mount a site's own request handler in the dev server.
|
|
8
|
+
*
|
|
9
|
+
* A site that talks to a backend needs one running to be developed against, and
|
|
10
|
+
* making that a live deployment is slow, costs money, and puts a shared database
|
|
11
|
+
* behind a developer's experiments. So a site may name a **local handler** and the
|
|
12
|
+
* dev server mounts it at the site's own service address:
|
|
13
|
+
*
|
|
14
|
+
* ```yaml
|
|
15
|
+
* # site.yml
|
|
16
|
+
* api: /_api # where the site's app backend answers
|
|
17
|
+
* devApi: ./mock/api.js # what answers it, in development only
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ```js
|
|
21
|
+
* // mock/api.js — default-export a fetch handler
|
|
22
|
+
* export default (request) => new Response('{}', { headers: { 'content-type': 'application/json' } })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## ⭐ The framework mounts; the site supplies
|
|
26
|
+
*
|
|
27
|
+
* This knows nothing about what it is mounting — not the routes, not the shapes,
|
|
28
|
+
* not which backend is being imitated. It takes a `Request` handler and puts it on
|
|
29
|
+
* a path. ⛔ **That is deliberate and load-bearing:** the moment the framework
|
|
30
|
+
* knows what a "mock backend" is, it has a favourite one, and a site talking to
|
|
31
|
+
* something else is a second-class citizen in its own dev server. A handler is the
|
|
32
|
+
* whole contract, and anything that can produce one — a hand-written stub, a
|
|
33
|
+
* recorded fixture, someone's real service in a function — mounts the same way.
|
|
34
|
+
*
|
|
35
|
+
* ## ⛔ Development only, and it cannot leak
|
|
36
|
+
*
|
|
37
|
+
* `devApi` is read by the dev plugin and by nothing else: no build reads it, no
|
|
38
|
+
* `info` key carries it, and nothing writes it into a payload. A site's *address*
|
|
39
|
+
* (`api:`) is authored config and travels; what answers that address locally is a
|
|
40
|
+
* fact about one machine.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **Same-origin on purpose.** Mounting inside the dev server means cookies and
|
|
43
|
+
* `credentials: 'same-origin'` behave as they do in production, where a site's app
|
|
44
|
+
* backend answers on the site's own origin. A handler on another port would work
|
|
45
|
+
* too, and would exercise CORS and third-party-cookie rules that production does
|
|
46
|
+
* not have — so a problem found that way might not be a real one.
|
|
47
|
+
*
|
|
48
|
+
* ## ⛔ Registered SYNCHRONOUSLY, and that is not a style choice
|
|
49
|
+
*
|
|
50
|
+
* Vite adds middleware registered during `configureServer` BEFORE its own — but
|
|
51
|
+
* only what is registered before that hook returns. An `await` first, and the
|
|
52
|
+
* middleware lands after the SPA fallback, which answers every path with
|
|
53
|
+
* `index.html`: the API returns a 200 of HTML, the client fails to parse it, and
|
|
54
|
+
* nothing in the log says why. So the config is read with `readFileSync` and the
|
|
55
|
+
* middleware goes on the stack immediately; only the module load is deferred, and
|
|
56
|
+
* the middleware awaits it on the first request.
|
|
57
|
+
*
|
|
58
|
+
* @param {import('vite').ViteDevServer} server
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {string} options.root - the site directory
|
|
61
|
+
* @returns {boolean} whether a handler was mounted
|
|
62
|
+
*/
|
|
63
|
+
export function mountDevApi(server, { root }) {
|
|
64
|
+
// ⛔ Read from the RAW site.yml, never from the collected `config`. `$`-prefixed
|
|
65
|
+
// keys are stripped from the payload precisely because they are local to a
|
|
66
|
+
// checkout — so the one place that needs this one goes to the file. That is the
|
|
67
|
+
// rule working: if it were readable from `config`, it would also be published.
|
|
68
|
+
let site
|
|
69
|
+
try {
|
|
70
|
+
site = yaml.load(readFileSync(join(root, 'site.yml'), 'utf8')) || {}
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const spec = site.$devApi
|
|
76
|
+
if (!spec) return false
|
|
77
|
+
|
|
78
|
+
const declared = site.api
|
|
79
|
+
const mount = typeof declared === 'string' ? declared : declared?.endpoint
|
|
80
|
+
if (!mount) {
|
|
81
|
+
console.error("[dev-api] `$devApi` needs an `api:` address to answer on — add `api: /_api` to site.yml.")
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Loaded once, lazily, and awaited by the middleware. ⚠️ Loud and specific on
|
|
86
|
+
// failure: a dev API that silently fails to load looks exactly like a backend
|
|
87
|
+
// refusing every request, and a developer debugs their own client for an hour
|
|
88
|
+
// before finding a typo in a path.
|
|
89
|
+
let loading = null
|
|
90
|
+
const getHandler = () => {
|
|
91
|
+
if (!loading) {
|
|
92
|
+
loading = server
|
|
93
|
+
.ssrLoadModule(pathToFileURL(resolve(root, spec)).href)
|
|
94
|
+
.then((loaded) => {
|
|
95
|
+
const handler = loaded?.default ?? loaded?.fetch
|
|
96
|
+
if (typeof handler !== 'function') {
|
|
97
|
+
throw new Error(`'${spec}' must default-export a function (request) => Response`)
|
|
98
|
+
}
|
|
99
|
+
return handler
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
console.error(`[dev-api] could not load '${spec}': ${err.message}`)
|
|
103
|
+
throw err
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return loading
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const prefix = mount.endsWith('/') ? mount.slice(0, -1) : mount
|
|
110
|
+
|
|
111
|
+
server.middlewares.use(async (req, res, next) => {
|
|
112
|
+
if (!req.url || (req.url !== prefix && !req.url.startsWith(`${prefix}/`))) return next()
|
|
113
|
+
|
|
114
|
+
// The handler sees the path WITHOUT the mount point: where a site chooses to
|
|
115
|
+
// expose its backend is the site's business, and a handler written against one
|
|
116
|
+
// deployment's prefix would not survive another's.
|
|
117
|
+
const inner = req.url.slice(prefix.length) || '/'
|
|
118
|
+
const origin = `http://${req.headers.host || 'localhost'}`
|
|
119
|
+
const init = { method: req.method, headers: req.headers }
|
|
120
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
121
|
+
const chunks = []
|
|
122
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
123
|
+
if (chunks.length) init.body = Buffer.concat(chunks)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const handler = await getHandler()
|
|
128
|
+
const response = await handler(new Request(new URL(inner, origin), init))
|
|
129
|
+
res.statusCode = response.status
|
|
130
|
+
response.headers.forEach((value, key) => res.setHeader(key, value))
|
|
131
|
+
const text = await response.text()
|
|
132
|
+
res.end(text || undefined)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.statusCode = 500
|
|
135
|
+
res.setHeader('content-type', 'application/json')
|
|
136
|
+
res.end(JSON.stringify({ status: 500, title: 'DevApiFailure', detail: err?.message }))
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
console.log(`[dev-api] '${spec}' answering ${prefix}/*`)
|
|
141
|
+
return true
|
|
142
|
+
}
|
package/src/site/config.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { importMapPlugin } from '../import-map-plugin.js'
|
|
32
32
|
import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
|
|
33
33
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
34
|
+
import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
|
|
34
35
|
import { detectFoundationType } from './foundation-ref.js'
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -191,7 +192,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
191
192
|
// Plugin to ensure foundation entry file exists (for bundled mode with local foundation)
|
|
192
193
|
const ensureFoundationEntryPlugin = !isRuntimeMode && foundationInfo.type === 'local' ? {
|
|
193
194
|
name: 'uniweb:ensure-foundation-entry',
|
|
194
|
-
async config() {
|
|
195
|
+
async config(_config, env) {
|
|
195
196
|
const srcDir = resolveFoundationSrcPath(foundationInfo.path)
|
|
196
197
|
const entryPath = join(srcDir, '_entry.generated.js')
|
|
197
198
|
|
|
@@ -202,8 +203,50 @@ export async function defineSiteConfig(options = {}) {
|
|
|
202
203
|
try {
|
|
203
204
|
await generateEntryPoint(srcDir, entryPath)
|
|
204
205
|
} catch (err) {
|
|
205
|
-
|
|
206
|
+
// ⛔ **Swallowing this on a BUILD is how you get vite's opaque
|
|
207
|
+
// `Failed to resolve entry for package "<name>"` 50ms later**: we
|
|
208
|
+
// decline to write the entry, say so in a `console.warn` that scrolls
|
|
209
|
+
// past, and hand vite a package whose `main` points at a file nobody
|
|
210
|
+
// created. The message naming the actual cause is the one we printed
|
|
211
|
+
// and discarded.
|
|
212
|
+
//
|
|
213
|
+
// ⚖️ **But only when there is no entry to fall back on.** If a previous
|
|
214
|
+
// build left one, it is stale rather than absent and the build can
|
|
215
|
+
// still complete — turning that into a hard failure would break a
|
|
216
|
+
// build that works today, which is a worse trade than a loud warning.
|
|
217
|
+
// Dev keeps warning either way: a transient error mid-session must not
|
|
218
|
+
// kill a running server.
|
|
219
|
+
const haveFallback = existsSync(entryPath)
|
|
220
|
+
if (env?.command === 'build' && !haveFallback) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[site] Could not generate the foundation entry for "${foundationInfo.name}".\n` +
|
|
223
|
+
` ${err.message}\n\n` +
|
|
224
|
+
` ${entryPath} does not exist, so the build cannot resolve the\n` +
|
|
225
|
+
` foundation. Fixing the error above is the fix; vite's next\n` +
|
|
226
|
+
` message about resolving package "${foundationInfo.name}" is a\n` +
|
|
227
|
+
` symptom of this one.`,
|
|
228
|
+
{ cause: err }
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
console.warn(
|
|
232
|
+
`[site] ⛔ Failed to generate foundation entry: ${err.message}` +
|
|
233
|
+
(haveFallback
|
|
234
|
+
? `\n[site] Continuing with the EXISTING ${entryPath}, which is now STALE.`
|
|
235
|
+
: '')
|
|
236
|
+
)
|
|
206
237
|
}
|
|
238
|
+
|
|
239
|
+
// Do we and vite agree on which directory that was? Two resolutions run
|
|
240
|
+
// here — ours by path, vite's by the bare specifier below
|
|
241
|
+
// (`alias['#foundation'] = foundationInfo.name`) — and when they diverge
|
|
242
|
+
// the quiet outcome is worse than the loud one: a stale entry in vite's
|
|
243
|
+
// directory builds the WRONG foundation and reports success.
|
|
244
|
+
const agreement = checkFoundationResolution({
|
|
245
|
+
name: foundationInfo.name,
|
|
246
|
+
generatedInto: srcDir,
|
|
247
|
+
siteRoot,
|
|
248
|
+
})
|
|
249
|
+
if (!agreement.ok) console.warn(`\n${agreement.message}\n`)
|
|
207
250
|
}
|
|
208
251
|
},
|
|
209
252
|
|
package/src/site/plugin.js
CHANGED
|
@@ -200,6 +200,7 @@ async function processDevSectionFetches(sections, fetchOptions) {
|
|
|
200
200
|
}
|
|
201
201
|
import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
|
|
202
202
|
import { mergeTranslations } from '../i18n/merge.js'
|
|
203
|
+
import { mountDevApi } from '../dev/api-mount.js'
|
|
203
204
|
|
|
204
205
|
/*
|
|
205
206
|
* `applyRouteTranslation` now comes from `@uniweb/projections` (imported
|
|
@@ -879,6 +880,18 @@ export function siteContentPlugin(options = {}) {
|
|
|
879
880
|
configureServer(devServer) {
|
|
880
881
|
server = devServer
|
|
881
882
|
|
|
883
|
+
// A site's own backend, answered locally in development. `site.yml::$devApi`
|
|
884
|
+
// names a module that default-exports a fetch handler, mounted at the site's
|
|
885
|
+
// own `api:` address so the address is identical in dev and in production.
|
|
886
|
+
// ⚠️ Synchronous on purpose — see mountDevApi. An await here and the
|
|
887
|
+
// middleware lands after Vite's SPA fallback, which answers the API with
|
|
888
|
+
// index.html and says nothing about why.
|
|
889
|
+
try {
|
|
890
|
+
mountDevApi(devServer, { root: resolvedSitePath })
|
|
891
|
+
} catch (err) {
|
|
892
|
+
console.error(`[dev-api] ${err.message}`)
|
|
893
|
+
}
|
|
894
|
+
|
|
882
895
|
// Watch for content changes in dev mode
|
|
883
896
|
if (shouldWatch) {
|
|
884
897
|
const siteYmlPath = resolve(resolvedSitePath, 'site.yml')
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Do WE and VITE agree on where the foundation is?
|
|
3
|
+
*
|
|
4
|
+
* ## The bug this exists for
|
|
5
|
+
*
|
|
6
|
+
* A site build resolves its foundation **twice, by two different mechanisms**,
|
|
7
|
+
* and until 2026-09-01 nothing checked that they agreed:
|
|
8
|
+
*
|
|
9
|
+
* 1. **We resolve a PATH.** `detectFoundationType('src', siteRoot)` returns
|
|
10
|
+
* `{ type: 'local', path: <siteRoot>/../src }`, and the
|
|
11
|
+
* `uniweb:ensure-foundation-entry` plugin generates `_entry.generated.js`
|
|
12
|
+
* into exactly that directory.
|
|
13
|
+
* 2. **Vite resolves a NAME.** `site/config.js` sets
|
|
14
|
+
* `alias['#foundation'] = foundationInfo.name` — the bare specifier — so
|
|
15
|
+
* Vite runs its own node_modules lookup and reads that package's `main`.
|
|
16
|
+
*
|
|
17
|
+
* Those name the same directory only because a package manager linked
|
|
18
|
+
* `node_modules/<name>` to the foundation. Nothing enforces it, and when it does
|
|
19
|
+
* not hold we generate into one directory while Vite reads another.
|
|
20
|
+
*
|
|
21
|
+
* ## ⛔ The failure is worse when it does NOT fail
|
|
22
|
+
*
|
|
23
|
+
* If the directory Vite reaches has no `_entry.generated.js`, the build dies with
|
|
24
|
+
* vite's `[commonjs--resolver] Failed to resolve entry for package "<name>"` —
|
|
25
|
+
* opaque, but loud.
|
|
26
|
+
*
|
|
27
|
+
* **If it has a STALE one, nothing fails at all.** The site builds against a
|
|
28
|
+
* different foundation than the one on disk, and reports success. A developer
|
|
29
|
+
* editing `src/` sees their change generated into `src/_entry.generated.js` and
|
|
30
|
+
* simply not take effect. *(Measured by the `flows` lane 2026-09-01: their
|
|
31
|
+
* harness minted fixtures under a `node_modules` that was itself a symlink to a
|
|
32
|
+
* seed tree, so a relative `../../src` link resolved into the seed. Every run
|
|
33
|
+
* that mutated its foundation and then built a site had been silently building
|
|
34
|
+
* the seed's copy — and presenting as green — for as long as the harness had
|
|
35
|
+
* existed. The red they opened a channel about was the harmless half.)*
|
|
36
|
+
*
|
|
37
|
+
* ⇒ **This check is about the silent case.** An error message can only reach the
|
|
38
|
+
* loud one; comparing the two answers reaches both.
|
|
39
|
+
*
|
|
40
|
+
* ## Why it compares REALPATHS and not link text
|
|
41
|
+
*
|
|
42
|
+
* `readlink node_modules/<name>` returning `../../src` looks healthy and proves
|
|
43
|
+
* nothing: **a relative symlink resolves against the link's target, not its
|
|
44
|
+
* location**, so with `node_modules` itself a link, `../../src` lands in a
|
|
45
|
+
* different tree. Only the resolved physical path answers the question vite
|
|
46
|
+
* actually asks. *(This module's first draft compared link text. It reported
|
|
47
|
+
* healthy on the one tree that was broken.)*
|
|
48
|
+
*
|
|
49
|
+
* ## Why it does its own node_modules walk
|
|
50
|
+
*
|
|
51
|
+
* `createRequire(...).resolve(name)` cannot be used: in the failure case `main`
|
|
52
|
+
* points at the missing entry, so it throws rather than telling us where it
|
|
53
|
+
* looked. `require.resolve(name + '/package.json')` cannot either — a foundation
|
|
54
|
+
* declares `exports: { ".": "./_entry.generated.js" }`, which gates every other
|
|
55
|
+
* subpath, so that lookup fails on a *healthy* package.
|
|
56
|
+
*
|
|
57
|
+
* So we walk `node_modules` upward for the package directory, which is the part
|
|
58
|
+
* of node resolution that locates a package before `exports` or `main` is
|
|
59
|
+
* consulted. That is the step whose answer we need.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
63
|
+
import { dirname, join, parse } from 'node:path'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The directory a bare specifier's package lives in, by node's own
|
|
67
|
+
* node_modules walk — the step that runs before `exports`/`main` are read.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} name - the bare specifier (a foundation's package name)
|
|
70
|
+
* @param {string} fromDir - the directory resolution starts in (the site root)
|
|
71
|
+
* @returns {string|null} the package directory, or null if no node_modules
|
|
72
|
+
* anywhere up the tree holds it
|
|
73
|
+
*/
|
|
74
|
+
export function findPackageDir(name, fromDir) {
|
|
75
|
+
let dir = fromDir
|
|
76
|
+
const { root } = parse(fromDir)
|
|
77
|
+
// eslint-disable-next-line no-constant-condition
|
|
78
|
+
while (true) {
|
|
79
|
+
const candidate = join(dir, 'node_modules', name)
|
|
80
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
81
|
+
if (dir === root) return null
|
|
82
|
+
const parent = dirname(dir)
|
|
83
|
+
if (parent === dir) return null
|
|
84
|
+
dir = parent
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compare where we generated the foundation entry against where vite will look.
|
|
90
|
+
*
|
|
91
|
+
* ⭐ **Silence is deliberate when the package is not found at all.** Not every
|
|
92
|
+
* supported layout puts the foundation in the site's `node_modules` — a
|
|
93
|
+
* `foundations/<name>/` multi-site project may not — and a warning that fires on
|
|
94
|
+
* a healthy project is worse than no warning, because the next person learns to
|
|
95
|
+
* ignore it. A package we cannot locate is also a case vite will fail on by
|
|
96
|
+
* itself, loudly. **We report only a disagreement we can actually prove**, which
|
|
97
|
+
* makes a false positive impossible by construction.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} args
|
|
100
|
+
* @param {string} args.name - the foundation's declared name (the bare specifier)
|
|
101
|
+
* @param {string} args.generatedInto - the directory we wrote `_entry.generated.js` to
|
|
102
|
+
* @param {string} args.siteRoot - where vite resolves the bare specifier from
|
|
103
|
+
* @returns {{ ok: true } | { ok: false, ours: string, theirs: string, message: string }}
|
|
104
|
+
*/
|
|
105
|
+
export function checkFoundationResolution({ name, generatedInto, siteRoot }) {
|
|
106
|
+
const theirsRaw = findPackageDir(name, siteRoot)
|
|
107
|
+
if (!theirsRaw) return { ok: true }
|
|
108
|
+
|
|
109
|
+
let ours, theirs
|
|
110
|
+
try {
|
|
111
|
+
ours = realpathSync(generatedInto)
|
|
112
|
+
theirs = realpathSync(theirsRaw)
|
|
113
|
+
} catch {
|
|
114
|
+
// A path vanished between the walk and the realpath. Not a disagreement we
|
|
115
|
+
// can prove, so it is not one we report.
|
|
116
|
+
return { ok: true }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ours === theirs) return { ok: true }
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
ours,
|
|
124
|
+
theirs,
|
|
125
|
+
message: [
|
|
126
|
+
`[site] ⛔ Foundation "${name}" resolves to two different directories.`,
|
|
127
|
+
``,
|
|
128
|
+
` we generated its entry into : ${ours}`,
|
|
129
|
+
` vite will import it from : ${theirs}`,
|
|
130
|
+
``,
|
|
131
|
+
` Your site build will use the SECOND one, so edits to the first do not`,
|
|
132
|
+
` take effect — and if that directory carries a stale _entry.generated.js`,
|
|
133
|
+
` the build SUCCEEDS against the wrong foundation.`,
|
|
134
|
+
``,
|
|
135
|
+
` This means node_modules/${name} under ${siteRoot} is not the foundation`,
|
|
136
|
+
` directory. Re-run your package manager's install. If you copied or moved`,
|
|
137
|
+
` this project, check whether node_modules (or a parent of it) is a symlink`,
|
|
138
|
+
` into another tree — a relative link inside one resolves against the link's`,
|
|
139
|
+
` target, so it can point outside this project while looking correct.`,
|
|
140
|
+
].join('\n'),
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -78,7 +78,8 @@ export function isContentBodyField(field) {
|
|
|
78
78
|
* @param {(ref: string) => string} [opts.resolveOptions] - maps an `options`
|
|
79
79
|
* (item_ref) ref to its full `@org/model/<section>` path. Falls back to
|
|
80
80
|
* `resolveName` (model only) when not supplied.
|
|
81
|
-
* @returns {Object} the declaration (`{ name, description?,
|
|
81
|
+
* @returns {Object} the declaration (`{ name, label?, description?, source_locale?,
|
|
82
|
+
* creatable_by?, linkable?, sections }`).
|
|
82
83
|
*/
|
|
83
84
|
export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
|
|
84
85
|
if (!name) throw new Error('toDataSchemaDeclaration: a registry name is required')
|
|
@@ -100,10 +101,40 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
const decl = { name }
|
|
104
|
+
if (normalized.label) decl.label = normalized.label
|
|
103
105
|
if (normalized.description) decl.description = normalized.description
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
106
|
+
if (normalized.sourceLocale) decl.source_locale = normalized.sourceLocale
|
|
107
|
+
// `creatable_by` — who may instantiate this Model. Omitted when the author
|
|
108
|
+
// declares nothing, because the registry's own default (open) is the absent
|
|
109
|
+
// meaning; sending it explicitly would state a policy the author did not.
|
|
110
|
+
if (normalized.creatableBy) decl.creatable_by = normalized.creatableBy
|
|
111
|
+
|
|
112
|
+
// `linkable` — DERIVED AND AUTHORED, and the two compose in one direction only.
|
|
113
|
+
//
|
|
114
|
+
// A brief-less model has no card to hydrate as an `entity_ref` target, so it
|
|
115
|
+
// cannot be linkable whatever it says; a model with a brief is linkable by
|
|
116
|
+
// default (omit ⇒ true). ⇒ **The derivation is a ceiling and the authored value
|
|
117
|
+
// may only lower it.** An author may say `linkable: false` on a model with a
|
|
118
|
+
// brief — a real choice, "do not let other models point at this" — and that is
|
|
119
|
+
// honoured.
|
|
120
|
+
//
|
|
121
|
+
// ⛔ The contradiction is refused rather than silently resolved: `linkable: true`
|
|
122
|
+
// on a brief-less model asks for something that cannot exist, and quietly
|
|
123
|
+
// ignoring it is how an author comes to believe a ref target works. Naming it
|
|
124
|
+
// costs one line and the message says which half to change.
|
|
125
|
+
if (!brief) {
|
|
126
|
+
if (normalized.linkable === true) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Data schema '${name}': 'linkable: true' needs a brief section — a model with no brief ` +
|
|
129
|
+
`has no card to hydrate when another model references it. Mark a section 'brief: true', ` +
|
|
130
|
+
`or drop 'linkable'.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
decl.linkable = false
|
|
134
|
+
} else if (normalized.linkable === false) {
|
|
135
|
+
decl.linkable = false
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
decl.sections = sections
|
|
108
139
|
return decl
|
|
109
140
|
}
|