@uniweb/build 0.30.1 → 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -61,10 +61,10 @@
61
61
  "yaml": "^2.5.0",
62
62
  "@uniweb/content-reader": "^1.2.4",
63
63
  "@uniweb/projections": "^0.5.2",
64
+ "@uniweb/schemas": "^0.2.12",
64
65
  "@uniweb/semantic-parser": "^1.3.1",
65
- "@uniweb/schemas": "^0.2.11",
66
- "@uniweb/content-writer": "^0.3.4",
67
- "@uniweb/theming": "^0.1.15"
66
+ "@uniweb/theming": "^0.1.15",
67
+ "@uniweb/content-writer": "^0.3.4"
68
68
  },
69
69
  "optionalDependencies": {
70
70
  "@uniweb/runtime": "^0.13.3"
@@ -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
+ }
@@ -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')
@@ -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?, linkable?, sections }`).
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
- // A brief-less model has no card to hydrate as an entity_ref target, so it is
105
- // not linkable; a model with a brief defaults to linkable (omit ⇒ true).
106
- if (!brief) decl.linkable = false
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
  }