@vectoriox/iox-ui 5.66.0 → 5.67.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.
@@ -1,13 +1,19 @@
1
- import { ComposedSite, PlaceholderKind } from './compose-site';
1
+ import { ComposedSite, parseSampleImageRef, PlaceholderKind } from './compose-site';
2
2
  import { patternById } from './patterns';
3
3
  import { createSerializedNode, SerializedNode, walkSerialized } from './serialized-node';
4
- import { SitePlan } from './site-plan.model';
4
+ import { ContentRole, roleField, SitePlan } from './site-plan.model';
5
+ import {
6
+ aspectRatioOf, scoreStockPhoto, STOCK_FAMILY_INTENTS, STOCK_INTENT_ORDER, StockIntent, StockNeed, stockFits, StockPhotoTraits, stockPhotoUsable,
7
+ stockWords,
8
+ } from './stock-match';
5
9
 
6
10
  /**
7
- * Stock photos in a built site (AI plan §3g / §3m, I2): a FREE picture from the platform's curated library, copied
11
+ * Stock photos in a built site (AI plan §3g / §3m, I2 + I2.1): a FREE picture from the platform's curated library, copied
8
12
  * into the studio's own storage (never hot-linked), placed only in an EMPTY picture slot of a section that may show
9
13
  * one — never the owner's work (a collection, a detail page, a gallery: what the nine portfolio types sell is theirs),
10
- * never a person (testimonials, the team), never anywhere when the business type says `stock: false`. Its alt text is
14
+ * never a person (testimonials, the team), never anywhere when the business type says `stock: false` — and, since I2.1
15
+ * (Doron, 2026-10-28), in the picture of a MARKED sample item (a product, a service, a dish) of a type that may show
16
+ * stock. Which photo goes where is chosen by what the slot is about (`stock-match.ts`). Its alt text is
11
17
  * the provider's description of what it shows (never a claim), and the credit line its licence requires — when it
12
18
  * requires one — is placed in the footer. Pure: the build step does the copying.
13
19
  */
@@ -67,23 +73,36 @@ export interface StockCredit {
67
73
  licenceUrl: string;
68
74
  }
69
75
 
70
- /** One picture of the platform library (`iox-stock/<type>/`), as its manifest lists it. */
71
- export interface StockPhoto {
72
- /** The library key (`spa/12345.jpg`) — the copy's file name is derived from it. */
76
+ /**
77
+ * One picture of the platform library (`iox-stock/<type>/`), as its manifest lists it. Everything past `credit` is
78
+ * optional (I2.1) — a manifest written before it is read as it was, and the planner derives what is missing.
79
+ */
80
+ export interface StockPhoto extends StockPhotoTraits {
81
+ /** The library key (`spa/12345.jpg`) — the copy's file name is derived from it, the build reads it from the library's address. */
73
82
  key: string;
74
- /** Where the build downloads it from (the platform bucket). */
83
+ /** Where the operator's run wrote it (kept for older readers; a build resolves `key` against the library's address). */
75
84
  url: string;
76
85
  /** What it shows, in the provider's words — the alt text. Never a claim. */
77
86
  alt: string;
78
- width?: number;
79
- height?: number;
80
87
  credit: StockCredit;
81
88
  }
82
89
 
83
90
  /** The section families a stock photo may stand in for a picture in. */
84
- export const STOCK_FAMILIES: ReadonlySet<string> = new Set(['hero', 'feature', 'content', 'story', 'cta', 'contact', 'header']);
91
+ export const STOCK_FAMILIES: ReadonlySet<string> = new Set(Object.keys(STOCK_FAMILY_INTENTS));
92
+
93
+ /** The aspect a list item's picture is shown at (Card v2's Image top, `composite-core` card layouts). */
94
+ export const STOCK_ITEM_ASPECT = 4 / 3;
95
+
96
+ export enum StockSlotKind {
97
+ /** An empty Image of a composed section. */
98
+ Section = 'section',
99
+ /** The image field of a marked sample item. */
100
+ Item = 'item',
101
+ }
85
102
 
86
- export interface StockSlot {
103
+ /** An empty picture of a composed section a stock photo may fill. */
104
+ export interface StockSlot extends StockNeed {
105
+ kind: `${StockSlotKind.Section}`;
87
106
  /** The Image node the photo goes into. */
88
107
  nodeId: string;
89
108
  /** The page key it lives on. */
@@ -91,31 +110,128 @@ export interface StockSlot {
91
110
  pattern: string;
92
111
  }
93
112
 
94
- /** The empty picture slots of a composed site a stock photo may fill, in page and section order. */
113
+ /** The image field of a sample item (a product, a service, a dish) a stock photo may fill. */
114
+ export interface StockItemSlot extends StockNeed {
115
+ kind: `${StockSlotKind.Item}`;
116
+ contentType: string;
117
+ /** The item's 0-based position in the content type's samples. */
118
+ index: number;
119
+ /** The field playing the type's `image` role. */
120
+ field: string;
121
+ }
122
+
123
+ export type StockTarget = StockSlot | StockItemSlot;
124
+
125
+ /** Where a photo may not appear twice: one page; one content type's samples (a list shows them together). */
126
+ export function stockGroupOf(target: StockTarget): string {
127
+ return target.kind === StockSlotKind.Section ? `page:${target.owner}` : `items:${target.contentType}`;
128
+ }
129
+
130
+ const TEXT_PROPS: ReadonlyArray<string> = ['content', 'label'];
131
+
132
+ /** The words of a subtree's text (Headings, Text, Links), the given node left out. */
133
+ function textOf(nodes: ReadonlyArray<SerializedNode>, skip: string): string[] {
134
+ const out: string[] = [];
135
+ walkSerialized(nodes, n => {
136
+ if (n.id === skip) return;
137
+ for (const prop of TEXT_PROPS) if (typeof n.props[prop] === 'string') out.push(n.props[prop] as string);
138
+ });
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * The empty picture slots of a composed site a stock photo may fill, in page and section order — each with its intent
144
+ * (the section's family), its aspect, and the words around it: the text beside it (its row), the section's slots and
145
+ * the page's name and search words.
146
+ */
95
147
  export function stockSlots(plan: SitePlan, composed: Pick<ComposedSite, 'pages' | 'placeholders'>): StockSlot[] {
96
148
  const empty = new Set(composed.placeholders.filter(p => p.kind === PlaceholderKind.Image).map(p => p.nodeId));
97
149
  const out: StockSlot[] = [];
98
150
  composed.pages.forEach((page, p) => {
151
+ const pagePlan = plan.pages[p];
99
152
  page.layout.forEach((root, s) => {
100
- const pattern = plan.pages[p]?.sections[s]?.pattern;
153
+ const section = pagePlan?.sections[s];
154
+ const pattern = section?.pattern;
101
155
  const family = patternById(pattern)?.family;
102
- if (!pattern || !family || !STOCK_FAMILIES.has(family)) return;
103
- walkSerialized([root], n => {
104
- if (n.type === 'Image' && empty.has(n.id) && !n.bindings.length) out.push({ nodeId: n.id, owner: page.key, pattern });
156
+ const intent = family ? STOCK_FAMILY_INTENTS[family] : undefined;
157
+ if (!pattern || !intent) return;
158
+ const sectionWords = [...Object.values(section?.slots ?? {}), pagePlan?.name, pagePlan?.seo?.title, pagePlan?.seo?.description];
159
+ walkSerialized([root], (n, ancestors) => {
160
+ if (n.type !== 'Image' || !empty.has(n.id) || n.bindings.length) return;
161
+ const row = ancestors[ancestors.length - 1];
162
+ out.push({
163
+ kind: StockSlotKind.Section, nodeId: n.id, owner: page.key, pattern, intent,
164
+ aspect: aspectRatioOf(n.styleProps['aspectRatio']),
165
+ words: stockWords(...(row ? textOf([row], n.id) : []), ...sectionWords),
166
+ });
105
167
  });
106
168
  });
107
169
  });
108
170
  return out;
109
171
  }
110
172
 
111
- export interface StockPlacement extends StockSlot {
112
- photo: StockPhoto;
173
+ /**
174
+ * The sample items' pictures a stock photo may fill: per content type with an `image` role, each MARKED sample whose
175
+ * image is still a bundled sample (`{sampleImage:k}`), with the type's and the item's own words.
176
+ */
177
+ export function stockItemSlots(plan: Pick<SitePlan, 'contentTypes'>, composed: Pick<ComposedSite, 'items'>): StockItemSlot[] {
178
+ const out: StockItemSlot[] = [];
179
+ for (const type of plan.contentTypes) {
180
+ const field = roleField(type, ContentRole.Image);
181
+ if (!field) continue;
182
+ const words = (item: Record<string, unknown>) => [type.label, type.name, ...[ContentRole.Title, ContentRole.Summary].map(r => item[roleField(type, r) ?? ''])];
183
+ (composed.items[type.name] ?? []).forEach((item, index) => {
184
+ if (!item.isSample || parseSampleImageRef(item[field]) === null) return;
185
+ out.push({ kind: StockSlotKind.Item, contentType: type.name, index, field, intent: StockIntent.Product, aspect: STOCK_ITEM_ASPECT, words: stockWords(...words(item)) });
186
+ });
187
+ }
188
+ return out;
113
189
  }
114
190
 
115
- /** Which photo goes where: the library's photos in order, one per slot, never twice; none when the type says no stock. */
116
- export function planStockPhotos(slots: ReadonlyArray<StockSlot>, photos: ReadonlyArray<StockPhoto>, stockAllowed: boolean): StockPlacement[] {
191
+ export type StockPlacement<T extends StockTarget = StockTarget> = T & { photo: StockPhoto };
192
+
193
+ /** What a placement run is told besides the slots and the library. */
194
+ export interface StockPlanOptions {
195
+ /** The business type's imagery line (harness-core `BusinessType.imagery`) — its words count for every slot. */
196
+ imagery?: string;
197
+ }
198
+
199
+ const intentRank = (intent: string) => STOCK_INTENT_ORDER.indexOf(intent as StockIntent);
200
+
201
+ /**
202
+ * Which photo goes where (I2.1). Slots choose in intent order (the hero first, list items last; page order within),
203
+ * each taking the best-scoring photo that FITS its shape (`stockFits`) and is not already on its page (or among its
204
+ * type's samples) — a photo no slot has taken yet always before one another page shows. Ties go to the library's
205
+ * order. A slot nothing fits keeps its empty box (a sample its abstract picture). None at all when the type says no
206
+ * stock, and never a photo the review rejected or flagged. Returned in the slots' order.
207
+ */
208
+ export function planStockPhotos<T extends StockTarget>(slots: ReadonlyArray<T>, photos: ReadonlyArray<StockPhoto>, stockAllowed: boolean, opts: StockPlanOptions = {}): Array<StockPlacement<T>> {
117
209
  if (!stockAllowed) return [];
118
- return slots.slice(0, photos.length).map((slot, i) => ({ ...slot, photo: photos[i] }));
210
+ const usable = photos.filter(stockPhotoUsable);
211
+ const imagery = stockWords(opts.imagery);
212
+ const order = slots.map((slot, i) => ({ slot, i })).sort((a, b) => intentRank(a.slot.intent) - intentRank(b.slot.intent) || a.i - b.i);
213
+ const used = new Set<string>();
214
+ const shownIn = new Map<string, Set<string>>();
215
+ const placed = new Map<number, StockPlacement<T>>();
216
+ for (const { slot, i } of order) {
217
+ const group = stockGroupOf(slot);
218
+ const here = shownIn.get(group) ?? new Set<string>();
219
+ const candidates = usable.filter(p => !here.has(p.key) && stockFits(p, slot));
220
+ const fresh = candidates.filter(p => !used.has(p.key));
221
+ const pool = fresh.length ? fresh : candidates;
222
+ let best: StockPhoto | undefined;
223
+ let bestScore = -Infinity;
224
+ for (const photo of pool) {
225
+ const score = scoreStockPhoto(photo, slot, imagery);
226
+ if (score > bestScore) { best = photo; bestScore = score; }
227
+ }
228
+ if (!best) continue;
229
+ used.add(best.key);
230
+ here.add(best.key);
231
+ shownIn.set(group, here);
232
+ placed.set(i, { ...slot, photo: best });
233
+ }
234
+ return slots.map((_, i) => placed.get(i)).filter((p): p is StockPlacement<T> => !!p);
119
235
  }
120
236
 
121
237
  /** A placed photo's copy in the studio's storage: the key the build writes it under. */
@@ -137,6 +253,21 @@ export function applyStockPhotos(trees: ReadonlyArray<SerializedNode>, placed: R
137
253
  return changed;
138
254
  }
139
255
 
256
+ /** Put the studio's copy of each photo into its sample item's image field. Returns the items changed. */
257
+ export function applyStockItemPhotos(
258
+ items: Readonly<Record<string, ReadonlyArray<Record<string, unknown>>>>,
259
+ placed: ReadonlyArray<{ contentType: string; index: number; field: string; url: string }>,
260
+ ): number {
261
+ let changed = 0;
262
+ for (const p of placed) {
263
+ const item = items[p.contentType]?.[p.index];
264
+ if (!item) continue;
265
+ item[p.field] = p.url;
266
+ changed++;
267
+ }
268
+ return changed;
269
+ }
270
+
140
271
  /** The line a picture's licence requires on the site, or null when it requires none (`STOCK_PROVIDER_TERMS`). */
141
272
  export function stockCreditLine(credit: Pick<StockCredit, 'provider'>): StockLink | null {
142
273
  return STOCK_PROVIDER_TERMS[credit.provider as StockProviderId]?.credit ?? null;