@stapel/search-react 0.12.0 → 0.13.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.
@@ -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
@@ -145,6 +145,7 @@ export {
145
145
  export { createSearchRuntime } from "./model/runtime.js";
146
146
  export type {
147
147
  SearchRuntime,
148
+ SearchImageResolver,
148
149
  CreateSearchRuntimeOptions,
149
150
  } from "./model/runtime.js";
150
151
  export {
@@ -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({ baseUrl: "/search/api/v1/" });
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 type SearchRuntime = ModuleRuntime<SearchApi>;
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 type CreateSearchRuntimeOptions = CreateModuleRuntimeOptions;
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
- return createModuleRuntime((client) => createSearchApi(client), options);
70
+ const base = createModuleRuntime((client) => createSearchApi(client), options);
71
+ return { ...base, resolveImage: options.resolveImage };
33
72
  }