@stapel/search-react 0.12.0 → 0.14.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/CHANGELOG.md +109 -0
- package/MODULE.md +7 -3
- package/dist/default/LocationSummaryLine.d.ts +19 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +111 -11
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +83 -37
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/cardPhotos.d.ts +65 -0
- package/dist/default/cardPhotos.d.ts.map +1 -0
- package/dist/default/cardPhotos.js +127 -0
- package/dist/default/cardPhotos.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +13 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/runtime.d.ts +40 -3
- package/dist/model/runtime.d.ts.map +1 -1
- package/dist/model/runtime.js +2 -1
- package/dist/model/runtime.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +14 -3
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/LocationSummaryLine.tsx +150 -22
- package/src/default/SearchResultCard.tsx +157 -71
- package/src/default/cardPhotos.ts +195 -0
- package/src/i18n/es.ts +3 -0
- package/src/i18n/keys.ts +13 -0
- package/src/i18n/ru.ts +3 -0
- package/src/index.ts +1 -0
- package/src/model/runtime.ts +43 -4
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A SEARCH CARD ACTUALLY STORES FOR A PHOTO, and how it becomes something
|
|
3
|
+
* `@stapel/image` can draw.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect this file exists to end ────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The default card used to read `card.image` expecting an object with a `url`
|
|
8
|
+
* key, and fall back to `card.image_url`. Neither is what this fleet emits:
|
|
9
|
+
*
|
|
10
|
+
* - `image_url` is a convention NOTHING in the fleet writes. It was declared
|
|
11
|
+
* in `GENERIC_CARD_FIELDS`, drawn in the demos from a data URI, and never
|
|
12
|
+
* once served by a backend.
|
|
13
|
+
* - `card.image` IS emitted — by `stapel-classified`'s search projection —
|
|
14
|
+
* and it is a plain `<type>/<hash>` STRING, not an object.
|
|
15
|
+
* - where a card DOES carry an object (chat's subject card, which serves the
|
|
16
|
+
* same CDN render descriptor its attachments carry), that object has `ref`
|
|
17
|
+
* and `variants[]` and NO top-level `url` — so the `"url" in rich` guard
|
|
18
|
+
* rejected the one rich shape the fleet has.
|
|
19
|
+
*
|
|
20
|
+
* The net effect was a card with no photo on every consumer that did not pass
|
|
21
|
+
* its own `renderCard`. Read the two real shapes instead, and read `images[]`
|
|
22
|
+
* first: since stapel-classified 0.7.0 the projection carries the whole
|
|
23
|
+
* gallery in seller order, deduplicated and capped by `CARD_IMAGES_LIMIT`,
|
|
24
|
+
* with the singular `image` kept as `images[0]`.
|
|
25
|
+
*
|
|
26
|
+
* ── Three shapes, one output ──────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* 1. A CDN reference (`"image/9f2c…"`) — resolved through the runtime's
|
|
29
|
+
* {@link SearchImageResolver}, the same seam `@stapel/listings-react`
|
|
30
|
+
* states, for the same reason: no contract in this fleet resolves a
|
|
31
|
+
* stranger's reference, so the deployment hands its own knowledge in once.
|
|
32
|
+
* 2. A URL a doc type stored directly (`"https://…"`, `"/media/…"`,
|
|
33
|
+
* `"data:image/…"`) — no ladder to shop, so it degrades to `source:
|
|
34
|
+
* "link"`. A CDN reference is `<type>/<hash>`: no scheme and no leading
|
|
35
|
+
* slash, so the two are told apart by shape and never by a guess.
|
|
36
|
+
* 3. A render descriptor object — `variants[]` with a `tier` each, an inline
|
|
37
|
+
* `preview_b64`, geometry. Read defensively (the field is `unknown` by
|
|
38
|
+
* contract) exactly as `@stapel/chat-react` reads the same descriptor.
|
|
39
|
+
*/
|
|
40
|
+
import type { StapelImage } from "@stapel/image";
|
|
41
|
+
import type { SearchImageResolver } from "../model/runtime.js";
|
|
42
|
+
|
|
43
|
+
/** A card's photo fields, and how many of them the card carried at all. */
|
|
44
|
+
export interface CardPhotos {
|
|
45
|
+
/**
|
|
46
|
+
* How many photo entries the card STORES — before resolving. `0` means the
|
|
47
|
+
* doc type has no photo for this row (or no photo field at all), which is a
|
|
48
|
+
* different thing from "a photo nothing could resolve", and the card draws
|
|
49
|
+
* the two differently.
|
|
50
|
+
*/
|
|
51
|
+
readonly stored: number;
|
|
52
|
+
/** The ones that became something drawable, in the stored order. */
|
|
53
|
+
readonly images: readonly StapelImage[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function str(value: unknown): string | undefined {
|
|
61
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function num(value: unknown): number | null {
|
|
65
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A stored string that is already a URL rather than a CDN reference.
|
|
70
|
+
*
|
|
71
|
+
* stapel-cdn's unit is `<type>/<hash>` — a relative pair with no scheme and no
|
|
72
|
+
* leading slash — so anything carrying a scheme, a protocol-relative prefix or
|
|
73
|
+
* a leading `/` is a URL the doc type stored itself.
|
|
74
|
+
*/
|
|
75
|
+
function isUrl(value: string): boolean {
|
|
76
|
+
return (
|
|
77
|
+
value.startsWith("/") ||
|
|
78
|
+
value.startsWith("data:") ||
|
|
79
|
+
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A bare URL as the descriptor `<Image>` consumes: one rung, no ladder. */
|
|
84
|
+
function linkImage(url: string): StapelImage {
|
|
85
|
+
return {
|
|
86
|
+
source: "link",
|
|
87
|
+
url,
|
|
88
|
+
mime: null,
|
|
89
|
+
width: null,
|
|
90
|
+
height: null,
|
|
91
|
+
aspect: null,
|
|
92
|
+
square: false,
|
|
93
|
+
preview_b64: null,
|
|
94
|
+
variants: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One rung of a render descriptor's ladder, in `@stapel/image`'s spelling.
|
|
100
|
+
*
|
|
101
|
+
* `tier` arrives in two spellings inside the SAME array — an int for the
|
|
102
|
+
* ladder rungs and the string sentinel `"original"` for the entry the snapshot
|
|
103
|
+
* builder appends. `String()` is right for both, which is why there is no
|
|
104
|
+
* branch here (`@stapel/cdn-react`'s `model/refs.ts` argues it at length).
|
|
105
|
+
*/
|
|
106
|
+
function rung(value: unknown): StapelImage["variants"][number] | undefined {
|
|
107
|
+
if (!isRecord(value)) return undefined;
|
|
108
|
+
const url = str(value["url"]);
|
|
109
|
+
if (url === undefined) return undefined;
|
|
110
|
+
const tier = value["tier"];
|
|
111
|
+
const branch = value["branch"];
|
|
112
|
+
return {
|
|
113
|
+
tier: typeof tier === "number" || typeof tier === "string" ? String(tier) : "",
|
|
114
|
+
branch: branch === "w" || branch === "h" ? branch : null,
|
|
115
|
+
url,
|
|
116
|
+
width: num(value["width"]),
|
|
117
|
+
height: num(value["height"]),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A CDN render descriptor → the descriptor `<Image>` consumes.
|
|
123
|
+
*
|
|
124
|
+
* The display URL is the descriptor's own `url` when it has one, else the
|
|
125
|
+
* `original` rung, else the largest rung — a snapshot carries no
|
|
126
|
+
* `original_url`, so the top of the ladder IS the original. With no rung at
|
|
127
|
+
* all the inline `preview_b64` is used: a real, honestly blurry image beats
|
|
128
|
+
* nothing. A descriptor with neither is not drawable and says so.
|
|
129
|
+
*/
|
|
130
|
+
function metaImage(meta: Readonly<Record<string, unknown>>): StapelImage | undefined {
|
|
131
|
+
const raw = meta["variants"];
|
|
132
|
+
const variants = (Array.isArray(raw) ? raw : [])
|
|
133
|
+
.map(rung)
|
|
134
|
+
.filter((v): v is StapelImage["variants"][number] => v !== undefined);
|
|
135
|
+
const original = variants.find((v) => v.tier === "original");
|
|
136
|
+
const largest = variants.reduce<StapelImage["variants"][number] | undefined>(
|
|
137
|
+
(best, v) => {
|
|
138
|
+
const size = Number(v.tier);
|
|
139
|
+
if (!Number.isFinite(size)) return best;
|
|
140
|
+
return best === undefined || size > Number(best.tier) ? v : best;
|
|
141
|
+
},
|
|
142
|
+
undefined
|
|
143
|
+
);
|
|
144
|
+
const preview = str(meta["preview_b64"]);
|
|
145
|
+
const url = str(meta["url"]) ?? original?.url ?? largest?.url ?? preview;
|
|
146
|
+
if (url === undefined) return undefined;
|
|
147
|
+
return {
|
|
148
|
+
source: "cdn",
|
|
149
|
+
url,
|
|
150
|
+
mime: str(meta["mime"]) ?? null,
|
|
151
|
+
width: num(meta["width"]),
|
|
152
|
+
height: num(meta["height"]),
|
|
153
|
+
aspect: num(meta["aspect"]),
|
|
154
|
+
square: meta["square"] === true,
|
|
155
|
+
preview_b64: preview ?? null,
|
|
156
|
+
variants,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One stored entry, in whichever of the three shapes it arrived. */
|
|
161
|
+
export function cardImage(
|
|
162
|
+
value: unknown,
|
|
163
|
+
resolve: SearchImageResolver | undefined
|
|
164
|
+
): StapelImage | undefined {
|
|
165
|
+
const ref = str(value);
|
|
166
|
+
if (ref !== undefined) {
|
|
167
|
+
return isUrl(ref) ? linkImage(ref) : resolve?.(ref);
|
|
168
|
+
}
|
|
169
|
+
if (isRecord(value)) return metaImage(value);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The card's gallery: `images[]` when the doc type carries one, the singular
|
|
175
|
+
* `image` otherwise.
|
|
176
|
+
*
|
|
177
|
+
* Never both — `stapel-classified` stores `image` as `images[0]`, so reading
|
|
178
|
+
* the singular after the list would draw the first photo twice. The singular
|
|
179
|
+
* stays the fallback for a doc type that never grew a list.
|
|
180
|
+
*/
|
|
181
|
+
export function readCardPhotos(
|
|
182
|
+
card: Readonly<Record<string, unknown>>,
|
|
183
|
+
resolve: SearchImageResolver | undefined
|
|
184
|
+
): CardPhotos {
|
|
185
|
+
const list = card["images"];
|
|
186
|
+
const stored: readonly unknown[] = Array.isArray(list)
|
|
187
|
+
? list
|
|
188
|
+
: card["image"] === undefined || card["image"] === null
|
|
189
|
+
? []
|
|
190
|
+
: [card["image"]];
|
|
191
|
+
const images = stored
|
|
192
|
+
.map((entry) => cardImage(entry, resolve))
|
|
193
|
+
.filter((image): image is StapelImage => image !== undefined);
|
|
194
|
+
return { stored: stored.length, images };
|
|
195
|
+
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -40,6 +40,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
40
40
|
"search.results.untitled": "Sin título",
|
|
41
41
|
"search.results.open": "Abrir",
|
|
42
42
|
"search.results.image_alt": "Foto de {title}",
|
|
43
|
+
"search.results.photo_alt": "Foto {index} de {total}: {title}",
|
|
44
|
+
"search.results.photos": "Fotos",
|
|
45
|
+
"search.results.photo_unavailable": "Foto no disponible",
|
|
43
46
|
|
|
44
47
|
"search.box.label": "Buscar",
|
|
45
48
|
"search.box.placeholder": "¿Qué estás buscando?",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -56,6 +56,16 @@ export const SEARCH_I18N_KEYS = {
|
|
|
56
56
|
resultsOpen: "search.results.open",
|
|
57
57
|
/** Alt text for the card photo — the card's own title, in a sentence. */
|
|
58
58
|
resultsImageAlt: "search.results.image_alt",
|
|
59
|
+
/** Alt text for one photo of a GALLERY: its place in the strip, and the
|
|
60
|
+
* card's title. A row of ten photos all called "Photo of X" is ten
|
|
61
|
+
* identical announcements. */
|
|
62
|
+
resultsPhotoAlt: "search.results.photo_alt",
|
|
63
|
+
/** The photo strip's accessible name. `SkinCarousel` takes its label from
|
|
64
|
+
* the caller for exactly this reason: the token bridge owns no i18n. */
|
|
65
|
+
resultsPhotos: "search.results.photos",
|
|
66
|
+
/** The card stores a reference and nothing resolved it — a sentence gets
|
|
67
|
+
* the wiring fixed; an empty grey box teaches nobody anything. */
|
|
68
|
+
resultsPhotoUnavailable: "search.results.photo_unavailable",
|
|
59
69
|
|
|
60
70
|
// ── the query box ────────────────────────────────────────────────────────
|
|
61
71
|
boxLabel: "search.box.label",
|
|
@@ -318,6 +328,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
318
328
|
"search.results.untitled": "Untitled",
|
|
319
329
|
"search.results.open": "Open",
|
|
320
330
|
"search.results.image_alt": "Photo of {title}",
|
|
331
|
+
"search.results.photo_alt": "Photo {index} of {total}: {title}",
|
|
332
|
+
"search.results.photos": "Photos",
|
|
333
|
+
"search.results.photo_unavailable": "Photo unavailable",
|
|
321
334
|
|
|
322
335
|
"search.box.label": "Search",
|
|
323
336
|
"search.box.placeholder": "What are you looking for?",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -63,6 +63,9 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
63
63
|
"search.results.untitled": "Без названия",
|
|
64
64
|
"search.results.open": "Открыть",
|
|
65
65
|
"search.results.image_alt": "Фото: {title}",
|
|
66
|
+
"search.results.photo_alt": "Фото {index} из {total}: {title}",
|
|
67
|
+
"search.results.photos": "Фотографии",
|
|
68
|
+
"search.results.photo_unavailable": "Фото недоступно",
|
|
66
69
|
|
|
67
70
|
"search.box.label": "Поиск",
|
|
68
71
|
"search.box.placeholder": "Что ищете?",
|
package/src/index.ts
CHANGED
package/src/model/runtime.ts
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
import { createModuleRuntime } from "@stapel/core";
|
|
2
2
|
import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
|
|
3
|
+
import type { StapelImage } from "@stapel/image";
|
|
3
4
|
import { createSearchApi } from "../api/searchApi.js";
|
|
4
5
|
import type { SearchApi } from "../api/searchApi.js";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Turn a stored CDN reference into something renderable.
|
|
9
|
+
*
|
|
10
|
+
* ── Why this is a seam and not a URL builder ───────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* A search card's photo fields (`image`, `images`) hold what the indexed doc
|
|
13
|
+
* type stores, and in this fleet that is an OPAQUE `<type>/<hash>` reference —
|
|
14
|
+
* the same unit `Listing.images` holds. No contract here resolves a stranger's
|
|
15
|
+
* reference: stapel-cdn's `file/exists/` is owner-scoped, so
|
|
16
|
+
* `@stapel/cdn-react`'s `useCdnRef` answers for a person's OWN draft and can
|
|
17
|
+
* never render somebody else's gallery. A pair that guessed `${base}/${ref}`
|
|
18
|
+
* would be writing a contract nobody agreed to, and it would break the first
|
|
19
|
+
* deployment that signs its media paths.
|
|
20
|
+
*
|
|
21
|
+
* So the deployment hands its own knowledge in once, exactly as
|
|
22
|
+
* `@stapel/listings-react`'s `ListingImageResolver` does — one seam, one
|
|
23
|
+
* spelling, and a container that already has a resolver passes the SAME
|
|
24
|
+
* function to both runtimes.
|
|
25
|
+
*
|
|
26
|
+
* A `StapelImage` rather than a string is what buys the variant ladder: with
|
|
27
|
+
* `variants` populated, `@stapel/image`'s `<Image>` measures the slot and
|
|
28
|
+
* picks a tier. A resolver with nothing for a reference returns `undefined`,
|
|
29
|
+
* and the skin then says "photo unavailable" instead of drawing a broken
|
|
30
|
+
* `<img>`.
|
|
31
|
+
*/
|
|
32
|
+
export type SearchImageResolver = (ref: string) => StapelImage | undefined;
|
|
33
|
+
|
|
6
34
|
/**
|
|
7
35
|
* The wired search runtime — core's `ModuleRuntime` bound to this pair's API
|
|
8
36
|
* (slim wave §21/S2). The returned `client` is what the host injects into
|
|
@@ -13,7 +41,10 @@ import type { SearchApi } from "../api/searchApi.js";
|
|
|
13
41
|
* storefront's catalogue, category and search pages need nothing but:
|
|
14
42
|
*
|
|
15
43
|
* ```tsx
|
|
16
|
-
* const runtime = createSearchRuntime({
|
|
44
|
+
* const runtime = createSearchRuntime({
|
|
45
|
+
* baseUrl: "/search/api/v1/",
|
|
46
|
+
* resolveImage: (ref) => myCdn.describe(ref),
|
|
47
|
+
* });
|
|
17
48
|
* <SearchProvider runtime={runtime}>…</SearchProvider>
|
|
18
49
|
* ```
|
|
19
50
|
*
|
|
@@ -22,12 +53,20 @@ import type { SearchApi } from "../api/searchApi.js";
|
|
|
22
53
|
* calls carry it — this pair neither requires nor waits for one, which is why
|
|
23
54
|
* its read hooks are deliberately not gated on `useActiveSessionReady`.
|
|
24
55
|
*/
|
|
25
|
-
export
|
|
56
|
+
export interface SearchRuntime extends ModuleRuntime<SearchApi> {
|
|
57
|
+
/** See {@link SearchImageResolver}. Absent = a card that stores references
|
|
58
|
+
* draws the placeholder and names the reason. */
|
|
59
|
+
readonly resolveImage: SearchImageResolver | undefined;
|
|
60
|
+
}
|
|
26
61
|
|
|
27
|
-
export
|
|
62
|
+
export interface CreateSearchRuntimeOptions extends CreateModuleRuntimeOptions {
|
|
63
|
+
/** See {@link SearchImageResolver}. */
|
|
64
|
+
readonly resolveImage?: SearchImageResolver;
|
|
65
|
+
}
|
|
28
66
|
|
|
29
67
|
export function createSearchRuntime(
|
|
30
68
|
options: CreateSearchRuntimeOptions
|
|
31
69
|
): SearchRuntime {
|
|
32
|
-
|
|
70
|
+
const base = createModuleRuntime((client) => createSearchApi(client), options);
|
|
71
|
+
return { ...base, resolveImage: options.resolveImage };
|
|
33
72
|
}
|