@uniweb/kit 0.13.5 → 0.13.7

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.5",
3
+ "version": "0.13.7",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,7 +44,7 @@
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
46
  "temml": "^0.13.2",
47
- "@uniweb/core": "^0.12.0",
47
+ "@uniweb/core": "^0.13.0",
48
48
  "@uniweb/semantic-parser": "^1.3.1",
49
49
  "@uniweb/scene": "^0.1.3"
50
50
  },
@@ -44,7 +44,7 @@
44
44
  * }
45
45
  */
46
46
 
47
- import { getUniweb, recordDataUrl } from '@uniweb/core'
47
+ import { getUniweb, resolveFetchConfigs, buildDetailConfig } from '@uniweb/core'
48
48
  import { useFetched } from './useFetched.js'
49
49
 
50
50
  /**
@@ -60,46 +60,68 @@ import { useFetched } from './useFetched.js'
60
60
  export function useEntityDetail(record, options = {}) {
61
61
  const collection = options?.collection
62
62
  const request = buildDetailRequest(record, collection)
63
- return useFetched(request)
63
+ const result = useFetched(request)
64
+
65
+ // No separate detail source for this collection — nothing was stripped from
66
+ // the cascade, so the record the caller already holds IS the whole record.
67
+ // Returning it beats both alternatives: `null` makes every caller null-check
68
+ // a case that cannot fail, and requesting the per-record file anyway is a
69
+ // guaranteed 404, because that file is only written for a `deferred:`
70
+ // collection. `useFetched` is still called above — unconditionally, as the
71
+ // rules of hooks require — and simply skips on a null request.
72
+ if (record && collection && !request) {
73
+ return { data: record, error: null, loading: false }
74
+ }
75
+ return result
64
76
  }
65
77
 
66
78
  /**
67
79
  * Build the fetch request for one record's full payload.
68
80
  *
69
- * Exported for tests only — not re-exported from the package index. The
70
- * static-file branch has to resolve to the same URL the build writes and the
71
- * same URL core's dynamic-route auto-detail injects; a test can only assert
72
- * that if it can call this without a React tree.
81
+ * ⛔ THIS MUST NOT DECIDE THE ADDRESS ITSELF, and it used to. It read
82
+ * `config.collections[name].detailUrl` directly and otherwise composed
83
+ * `/data/<name>/<slug>.json` by hand, which was wrong three ways: it 404'd on
84
+ * any collection without `deferred:` (that file is only written for one), it
85
+ * could not see a host's live record lane at all, and its hand-rolled `{slug}`
86
+ * replace ignored a route whose param is named anything else.
87
+ *
88
+ * Every one of those is already solved once, in the resolution the runtime and
89
+ * the prerenderer share. So this hands the collection to that resolution and
90
+ * asks it the same question they ask: `resolveFetchConfigs` decides where the
91
+ * collection lives (a host's lane, or the compiled artifact) and what its
92
+ * per-record source is, then `buildDetailConfig` turns that plus a param into a
93
+ * request. A fourth answer computed here is a fourth thing to drift.
94
+ *
95
+ * Returns null when the collection has no separate detail source — the common
96
+ * case, and not a failure. The caller's record is already whole.
97
+ *
98
+ * Exported for tests only — not re-exported from the package index.
73
99
  *
74
100
  * @param {Object|null} record
75
101
  * @param {string} collection
76
- * @returns {{path?: string, url?: string, schema: string}|null}
102
+ * @returns {{path?: string, url?: string, endpoint?: string, schema: string}|null}
77
103
  */
78
104
  export function buildDetailRequest(record, collection) {
79
105
  const slug = record?.slug
80
106
  if (!slug || !collection) return null
81
107
 
82
- // Look up the collection's per-record source pattern. Authors with
83
- // API-backed collections declare `detailUrl:` in site.yml; markdown
84
- // collections leave it null and use the static-file default.
85
108
  const website = getUniweb()?.activeWebsite
86
- const collConfig = website?.config?.collections?.[collection]
87
- const detailUrl = (collConfig && typeof collConfig.detailUrl === 'string')
88
- ? collConfig.detailUrl
89
- : null
109
+ const config = website?.config
90
110
 
91
- if (detailUrl) {
92
- // Substitute {slug} into the author-declared pattern and use url:
93
- // (the source is remote).
94
- const url = detailUrl.replace(/\{slug\}/g, encodeURIComponent(slug))
95
- return { url, schema: collection }
96
- }
111
+ // One synthetic source, resolved by the shared rule — same inputs the
112
+ // EntityStore passes, so the hook cannot disagree with the page it sits on.
113
+ const resolved = resolveFetchConfigs([{ collection, schema: collection }], {
114
+ collections: config?.collections ?? null,
115
+ records: config?.records ?? null,
116
+ locale: website?.getActiveLocale?.() ?? null,
117
+ defaultLocale: website?.getDefaultLocale?.() ?? null,
118
+ }).get(collection)
119
+
120
+ if (!resolved) return null
97
121
 
98
- // Static-file default — the build emitted per-record JSON files. Shares
99
- // `recordDataUrl` with the build that writes them and with core's
100
- // dynamic-route auto-detail injection, which is the other half of this
101
- // same feature: the three must resolve one record to one URL.
102
- return { path: recordDataUrl(collection, slug), schema: collection }
122
+ // `slug` is this hook's contract with its caller (the record must carry one).
123
+ // The `{param}` alias means a host-written record pattern resolves too.
124
+ return buildDetailConfig(resolved, { paramName: 'slug', paramValue: slug })
103
125
  }
104
126
 
105
127
  export default useEntityDetail
@@ -142,6 +142,37 @@ export function createSearchClient(website, options = {}) {
142
142
  // host serves none.
143
143
  const authoredProvider = website?.config?.search?.provider
144
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
+
145
176
  // ⛔ THE HOST ANSWERED AND DECLINED. `resolveService` returns
146
177
  // `{ url: null, source: 'host' }` when a host declares the service NAME
147
178
  // while offering no address — the decline shape `@uniweb/core/services`
@@ -173,7 +204,7 @@ export function createSearchClient(website, options = {}) {
173
204
 
174
205
  const declared =
175
206
  providerOverride ||
176
- authoredProvider ||
207
+ authoredVeto ||
177
208
  (service.source === 'host' ? 'endpoint' : 'index')
178
209
 
179
210
  // One in-flight resolution shared by every caller.
@@ -168,21 +168,32 @@ export function createEndpointProvider(website, options = {}) {
168
168
  // more than "403". `isEnabled()` is what a foundation asks, and it is
169
169
  // false long before this line runs.
170
170
  //
171
- // ⚠️ Ordering, if the local-index fallback in `client.js` is ever
172
- // removed: that fallback is currently what stops this string
173
- // propagating, so removing it would surface this message where nothing
174
- // renders one today. Settle where a provider error may surface FIRST.
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.
175
186
  let declined
176
187
  try {
177
188
  declined = (await response.json())?.error
178
189
  } catch {
179
190
  /* not JSON — the status is all there is */
180
191
  }
181
- throw new Error(
182
- typeof declined === 'string' && declined.trim()
183
- ? declined.trim()
184
- : `Search endpoint returned ${response.status}`
185
- )
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}`)
186
197
  }
187
198
 
188
199
  const payload = await response.json()