@uniweb/kit 0.13.4 → 0.13.6

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/kit",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,8 +44,8 @@
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
46
  "temml": "^0.13.2",
47
- "@uniweb/core": "^0.11.3",
48
- "@uniweb/semantic-parser": "^1.3.0",
47
+ "@uniweb/semantic-parser": "^1.3.1",
48
+ "@uniweb/core": "^0.12.1",
49
49
  "@uniweb/scene": "^0.1.3"
50
50
  },
51
51
  "peerDependencies": {
@@ -10,12 +10,28 @@
10
10
  *
11
11
  * Resolution order:
12
12
  * 1. `site.yml search.provider` — the author's explicit choice
13
- * 2. `'index'` — the free default, works on any host
13
+ * 2. the HOST's offer — `config.services.search` in the payload,
14
+ * read through `resolveService` → `endpoint`
15
+ * 3. `'index'` — the free default, works on any host
14
16
  *
15
- * A future tier sits between them: a host that serves search declaring it in
16
- * the payload it already sends, the way `config.base` already tells the runtime
17
- * where the site is being served from. That wire key is the host's to specify,
18
- * so it is deliberately not invented here.
17
+ * ⛔ Tier 2 SHIPPED. This block described it as "a future tier … deliberately
18
+ * not invented here" until 2026-08-25, while `resolveService` was imported at
19
+ * the top of this file and read ~100 lines below. The wire key is
20
+ * `config.services.search`, resolved by `@uniweb/core/services` on the same
21
+ * two-tier rule every site service uses (site-authored wins, host fills gaps).
22
+ *
23
+ * ⚠️ That stale sentence had a cost outside this repo. The hosting lane could
24
+ * not tell from reading whether kit consumed the block, asked in a collab
25
+ * channel, and the answer mattered: on the sync/link lane the framework emits
26
+ * NO search index at all (removed 2026-08-01, `@uniweb/build`
27
+ * `site/build-site-data.js`), so tier 3 requests a `search-index.json` that
28
+ * nothing on that lane writes. A reader of this comment concluded the host tier
29
+ * did not exist and the block was decoration; both halves were wrong.
30
+ *
31
+ * ⭐ So tier 3 is "the free default" only where something EMITS the index —
32
+ * the bundle lane and any static host. It is not a universal fallback, and a
33
+ * host that answers search at its own address must be declared for kit to
34
+ * reach it.
19
35
  *
20
36
  * Provider contract (duck-typed, matching how the FetcherDispatcher treats
21
37
  * transports):
@@ -126,9 +142,69 @@ export function createSearchClient(website, options = {}) {
126
142
  // host serves none.
127
143
  const authoredProvider = website?.config?.search?.provider
128
144
 
145
+ // ⛔ …BUT AN AUTHORED `index` IS NOT A CHOICE, IT IS THE DEFAULT SPELLED OUT.
146
+ // `getSearchConfig()` fills `provider: 'index'` when the author said nothing,
147
+ // and `docs/authoring/search.md` shows it in its options block labelled
148
+ // "default" — so a site that copied that block to set `include:` or
149
+ // `exclude:` carries it without having decided anything.
150
+ //
151
+ // Treating it as a veto pinned exactly those sites to the local index. On a
152
+ // host-served site that index does not exist (the build stops emitting one on
153
+ // that lane), so an ENTITLED site — one whose host does offer search — drew a
154
+ // box, queried `search-index.json`, and got a 404. They had bought the
155
+ // feature and could not use it.
156
+ //
157
+ // ⇒ An authored `index` no longer outranks a host's offer. Any OTHER authored
158
+ // provider still does: `endpoint` and a foundation transport are real choices
159
+ // and the comment above still applies to them.
160
+ //
161
+ // ⚠️ This removes one thing an author could previously express — "use the
162
+ // local index even though my host offers search". On a host-served lane that
163
+ // preference resolves to a 404 either way, and on a static host `source` is
164
+ // never 'host' so this branch cannot fire and the local index is untouched.
165
+ //
166
+ // ⛔ REACHES NEW AND REBUILT FOUNDATIONS ONLY. A foundation bundles its own
167
+ // frozen copy of this file, so an already-published one keeps the old
168
+ // precedence. Unlike the host-decline check — which lives in `@uniweb/core`
169
+ // precisely so the runtime carries it to every foundation regardless of age —
170
+ // provider selection is kit's, and frozen kit reads `website.config` directly.
171
+ // There is no honest core-side fix: it would mean rewriting authored config
172
+ // to steer a consumer's branch.
173
+ const authoredVeto =
174
+ authoredProvider && authoredProvider !== 'index' ? authoredProvider : null
175
+
176
+ // ⛔ THE HOST ANSWERED AND DECLINED. `resolveService` returns
177
+ // `{ url: null, source: 'host' }` when a host declares the service NAME
178
+ // while offering no address — the decline shape `@uniweb/core/services`
179
+ // documents ("absence is a behavioural decision rather than a message").
180
+ //
181
+ // Falling through to `'index'` here would be wrong twice: the host has said
182
+ // it does not serve search, and on a host-served lane the framework emits no
183
+ // index to fall back to, so the fallback resolves to a 404. Draw nothing
184
+ // instead — `isEnabled()` reports false and `query()` returns empty without
185
+ // a request.
186
+ //
187
+ // ⚠️ Only a DECLINE does this. `source: null` — nobody declared the service
188
+ // at all — still means `'index'`, which is correct and is the static-host
189
+ // path: there the build DOES emit the index.
190
+ //
191
+ // ⛔ DO NOT ADD A FALLBACK THAT FIRES ON THIS SHAPE. The contract, settled
192
+ // with a host implementation 2026-08-25: **a host that OFFERS a service must
193
+ // declare it WITH an address** — offered is `{endpoint}`, declined is `{}`.
194
+ // Routing `{}` to `endpoint-provider`'s `DEFAULT_ENDPOINT` looks like a free
195
+ // saving and makes an offered service indistinguishable from a declined one:
196
+ // no search box, no error, nothing to grep for.
197
+ //
198
+ // ⭐ A site's OWN declaration still wins — `resolveService` answers tier 1
199
+ // with `source: 'site'`, so an operator running self-hosted search on a host
200
+ // that does not sell it is untouched. Pinned in
201
+ // `tests/search-host-decline.test.js`; that is the case worth re-running if
202
+ // this branch is ever edited, because the headline case never breaks.
203
+ const hostDeclined = service.source === 'host' && !service.url
204
+
129
205
  const declared =
130
206
  providerOverride ||
131
- authoredProvider ||
207
+ authoredVeto ||
132
208
  (service.source === 'host' ? 'endpoint' : 'index')
133
209
 
134
210
  // One in-flight resolution shared by every caller.
@@ -193,11 +269,21 @@ export function createSearchClient(website, options = {}) {
193
269
 
194
270
  return {
195
271
  /**
196
- * Check if search is enabled
272
+ * Check if search is enabled.
273
+ *
274
+ * Two independent ways it is off, and a caller needs neither to tell them
275
+ * apart: the SITE disabled it (`search: false` / `search: { enabled:
276
+ * false }`), or the HOST declared the service and offered no address —
277
+ * see `hostDeclined` above. Either way a foundation draws no search UI.
278
+ *
279
+ * ⛔ Deliberately no reason string. Which services a site is provisioned
280
+ * for is not a visitor's business and could not be localized from a public
281
+ * package anyway — the rule `@uniweb/core/services` states at length.
282
+ *
197
283
  * @returns {boolean}
198
284
  */
199
285
  isEnabled() {
200
- return website.isSearchEnabled()
286
+ return website.isSearchEnabled() && !hostDeclined
201
287
  },
202
288
 
203
289
  /**
@@ -273,6 +359,13 @@ export function createSearchClient(website, options = {}) {
273
359
  return { results: [], total: 0 }
274
360
  }
275
361
 
362
+ // The host declared the service and offered no address. Return empty
363
+ // rather than requesting anything — the alternative is a fetch we
364
+ // already know the answer to. Silent: a foundation that checked
365
+ // `isEnabled()` never reaches this, and one that did not should not fill
366
+ // a visitor's console over a service the site was never provisioned for.
367
+ if (hostDeclined) return { results: [], total: 0 }
368
+
276
369
  const opts = { limit, type, route, signal }
277
370
 
278
371
  try {
@@ -16,7 +16,29 @@
16
16
  import { emptyResult } from './result.js'
17
17
  import { resolveServiceUrl } from '../../utils/services.js'
18
18
 
19
- /** Path used when a site declares `provider: endpoint` without an `endpoint:`. */
19
+ /**
20
+ * Path used when a site declares `provider: endpoint` without an `endpoint:`.
21
+ *
22
+ * ⛔ THIS COVERS THE AUTHORED TIER ONLY — it is unreachable for a HOST-declared
23
+ * service, and it must stay that way.
24
+ *
25
+ * A host that declares the service name while offering no address is a
26
+ * **decline** (`@uniweb/core/services`), and `client.js` short-circuits on it
27
+ * before any provider is selected. So `{ "search": {} }` from a host means
28
+ * "draw nothing", never "use the default".
29
+ *
30
+ * ⇒ **The contract, settled with a host implementation 2026-08-25: a host that
31
+ * OFFERS a service must declare it WITH an address.** Offered is `{endpoint}`;
32
+ * declined is `{}`. The two are one byte apart and the failure is silent — an
33
+ * entitled site drawing no search box.
34
+ *
35
+ * ⚠️ **The saving that looks available from either side, and is not.** From
36
+ * here: *"a host need not send an address, kit defaults to `_search` anyway."*
37
+ * From the emitter: *"why emit `/_search` when kit defaults to it?"* **Both
38
+ * collapse the two states into one.** It was proposed and withdrawn the day
39
+ * this was written, by the lane that had just shipped the decline branch — the
40
+ * hazard does not look like a hazard while you are authoring it.
41
+ */
20
42
  const DEFAULT_ENDPOINT = '_search'
21
43
 
22
44
  /**
@@ -126,27 +148,52 @@ export function createEndpointProvider(website, options = {}) {
126
148
  })
127
149
 
128
150
  if (!response.ok) {
129
- // An endpoint that declines usually explains why, in words meant for a
130
- // visitor rather than a status code — "Search is not available on this
131
- // site." Read it and carry it, the same way `submitForm` does with a
132
- // rejected submission.
151
+ // ⛔ A HOST'S DECLINE TEXT IS A DIAGNOSTIC, NEVER VISITOR COPY.
133
152
  //
134
- // This does not change the degradation: a failed search still falls
135
- // back to the local index, because a failed READ should degrade and
136
- // that guarantee is older than this. What changes is that when there is
137
- // no index to fall back to, the error a foundation can surface says
138
- // something a person understands instead of a number.
153
+ // This block used to argue the opposite — that an endpoint "explains
154
+ // why, in words meant for a visitor", so kit should carry that sentence
155
+ // up for a foundation to render. That is wrong, and it was ruled
156
+ // against on 2026-08-25: a control for a service the site does not have
157
+ // must not be DRAWN in the first place, so there is no place for an
158
+ // apology and nothing for a visitor to read. A site with no search is
159
+ // not broken; it simply has no search, the same way it has no contact
160
+ // form when submissions are not enabled.
161
+ //
162
+ // Two reasons it cannot be visitor copy even if one wanted it to be:
163
+ // it reports the operator's provisioning state to the public, and it
164
+ // arrives in one language from a service layer, bypassing the site's
165
+ // localization entirely. Any text a visitor reads is site content.
166
+ //
167
+ // ⇒ The message is kept because a developer reading a console needs
168
+ // more than "403". `isEnabled()` is what a foundation asks, and it is
169
+ // false long before this line runs.
170
+ //
171
+ // ⛔ THE HOST'S SENTENCE GOES TO THE CONSOLE, NEVER INTO THE THROW.
172
+ //
173
+ // It used to become the Error's message, and `useSearch` exposes
174
+ // `error` — so a foundation rendering it would have shown a visitor a
175
+ // host-authored English sentence about someone's provisioning state.
176
+ // The only thing preventing that was `client.js`'s local-index
177
+ // fallback swallowing the throw, which is incidental: that fallback
178
+ // exists to DEGRADE, and removing it (reasonable — on a host-served
179
+ // lane there is no index to fall back to) would have surfaced the
180
+ // sentence as its first act.
181
+ //
182
+ // ⇒ Splitting the two registers removes the ordering hazard entirely
183
+ // rather than documenting it: whatever reaches `error` is now
184
+ // framework-authored and carries no host prose, so the fallback can be
185
+ // removed on its own merits whenever someone wants to.
139
186
  let declined
140
187
  try {
141
188
  declined = (await response.json())?.error
142
189
  } catch {
143
190
  /* not JSON — the status is all there is */
144
191
  }
145
- throw new Error(
146
- typeof declined === 'string' && declined.trim()
147
- ? declined.trim()
148
- : `Search endpoint returned ${response.status}`
149
- )
192
+ if (typeof declined === 'string' && declined.trim()) {
193
+ // Developer register: a console reader needs more than a number.
194
+ console.warn(`[uniweb] search endpoint declined: ${declined.trim()}`)
195
+ }
196
+ throw new Error(`Search endpoint returned ${response.status}`)
150
197
  }
151
198
 
152
199
  const payload = await response.json()