@aglyn/plugins-ai 1.0.0-beta.240 → 1.0.0-beta.242

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.
Files changed (92) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-job-links.d.ts +17 -0
  3. package/src/lib/components/ai-job-links.js +63 -0
  4. package/src/lib/components/ai-job-links.js.map +1 -1
  5. package/src/lib/components/ai-job-plan.component.js +8 -1
  6. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  7. package/src/lib/components/ai-site-build-page.component.js +7 -1
  8. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  9. package/src/lib/components/ai-store-finish-card.component.d.ts +28 -0
  10. package/src/lib/components/ai-store-finish-card.component.js +116 -0
  11. package/src/lib/components/ai-store-finish-card.component.js.map +1 -0
  12. package/src/lib/components/assist-build-card.component.js +12 -1
  13. package/src/lib/components/assist-build-card.component.js.map +1 -1
  14. package/src/lib/jobs/ai-job-build-step.d.ts +45 -1
  15. package/src/lib/jobs/ai-job-build-step.js +283 -7
  16. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  17. package/src/lib/jobs/ai-job-drafts.d.ts +9 -1
  18. package/src/lib/jobs/ai-job-drafts.js +4 -1
  19. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  20. package/src/lib/jobs/ai-job-layout-language.d.ts +3 -0
  21. package/src/lib/jobs/ai-job-layout-language.js +4 -2
  22. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  23. package/src/lib/jobs/ai-job-layout-step.js +3 -1
  24. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  25. package/src/lib/jobs/ai-job-page-language.d.ts +2 -1
  26. package/src/lib/jobs/ai-job-page-language.js +19 -7
  27. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  28. package/src/lib/jobs/ai-job-page-sections.d.ts +11 -0
  29. package/src/lib/jobs/ai-job-page-sections.js +40 -3
  30. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  31. package/src/lib/jobs/ai-job-page-step.d.ts +2 -1
  32. package/src/lib/jobs/ai-job-page-step.js +19 -8
  33. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  34. package/src/lib/jobs/ai-job-plan-step.d.ts +12 -0
  35. package/src/lib/jobs/ai-job-plan-step.js +41 -7
  36. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-site-content.js +3 -2
  38. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  39. package/src/lib/jobs/ai-job-site-datasets.d.ts +76 -2
  40. package/src/lib/jobs/ai-job-site-datasets.js +206 -13
  41. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  42. package/src/lib/jobs/ai-job-site-step.d.ts +18 -2
  43. package/src/lib/jobs/ai-job-site-step.js +109 -22
  44. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  45. package/src/lib/jobs/ai-job-site-store-pages.d.ts +75 -0
  46. package/src/lib/jobs/ai-job-site-store-pages.js +195 -0
  47. package/src/lib/jobs/ai-job-site-store-pages.js.map +1 -0
  48. package/src/lib/jobs/ai-job-store-links-layout.d.ts +71 -0
  49. package/src/lib/jobs/ai-job-store-links-layout.js +95 -0
  50. package/src/lib/jobs/ai-job-store-links-layout.js.map +1 -0
  51. package/src/lib/jobs/ai-layout-stock-photos.d.ts +129 -8
  52. package/src/lib/jobs/ai-layout-stock-photos.js +919 -75
  53. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  54. package/src/lib/jobs/ai-site-publish.d.ts +18 -0
  55. package/src/lib/jobs/ai-site-publish.js +76 -15
  56. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  57. package/src/lib/jobs/fixtures/ai-stock-photo-ember-oak-hits.d.ts +43 -0
  58. package/src/lib/jobs/fixtures/ai-stock-photo-ember-oak-hits.js +61 -0
  59. package/src/lib/jobs/fixtures/ai-stock-photo-ember-oak-hits.js.map +1 -0
  60. package/src/lib/jobs/fixtures/ai-stock-photo-saltmarsh-recordings.d.ts +39 -0
  61. package/src/lib/jobs/fixtures/ai-stock-photo-saltmarsh-recordings.js +508 -0
  62. package/src/lib/jobs/fixtures/ai-stock-photo-saltmarsh-recordings.js.map +1 -0
  63. package/src/lib/layout-language/ai-layout-compiler.js +25 -2
  64. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  65. package/src/lib/layout-language/ai-layout-frame.d.ts +10 -0
  66. package/src/lib/layout-language/ai-layout-frame.js +58 -4
  67. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  68. package/src/lib/layout-language/ai-layout-listings.d.ts +6 -0
  69. package/src/lib/layout-language/ai-layout-listings.js +3 -0
  70. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  71. package/src/lib/model/ai-build-job.d.ts +2 -0
  72. package/src/lib/model/ai-build-job.js +23 -3
  73. package/src/lib/model/ai-build-job.js.map +1 -1
  74. package/src/lib/model/ai-build-plan.d.ts +7 -0
  75. package/src/lib/model/ai-build-plan.js.map +1 -1
  76. package/src/lib/model/ai-build-progress.js +3 -1
  77. package/src/lib/model/ai-build-progress.js.map +1 -1
  78. package/src/lib/model/ai-build-store-pages.d.ts +133 -0
  79. package/src/lib/model/ai-build-store-pages.js +209 -0
  80. package/src/lib/model/ai-build-store-pages.js.map +1 -0
  81. package/src/lib/model/ai-layout-store-links.d.ts +64 -0
  82. package/src/lib/model/ai-layout-store-links.js +165 -0
  83. package/src/lib/model/ai-layout-store-links.js.map +1 -0
  84. package/src/lib/model/ai-site-inventory.d.ts +5 -0
  85. package/src/lib/model/ai-site-inventory.js.map +1 -1
  86. package/src/lib/model/ai-site-store-pages.d.ts +192 -0
  87. package/src/lib/model/ai-site-store-pages.js +383 -0
  88. package/src/lib/model/ai-site-store-pages.js.map +1 -0
  89. package/src/lib/runtime/site-inventory.js +8 -3
  90. package/src/lib/runtime/site-inventory.js.map +1 -1
  91. package/src/lib/testing/stand-in-dataset-index.js +8 -4
  92. package/src/lib/testing/stand-in-dataset-index.js.map +1 -1
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ import { pluginMediaIngest } from "@aglyn/aglyn/plugin-manager/plugin-media-ingest";
18
18
  import { STOCK_PHOTO_QUERY_MAX_CHARS, stockPhotoProvider, stockPhotoSourceKey } from "@aglyn/aglyn/plugin-manager/stock-photo-provider";
19
19
  import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
20
+ import { aiStorefrontSays } from "../layout-language/ai-layout-storefront.js";
20
21
  /**
21
22
  * STOCK PHOTOS FOR A LANGUAGE PAGE'S PICTURE SLOTS (AGL-3660).
22
23
  *
@@ -70,6 +71,17 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
70
71
  * ranked: the object in the hit's first tags (what the photo is OF) over
71
72
  * a mention in its last, the subject's other words, the domain. Among
72
73
  * equals the job's seed picks, so two sites differ and one job repeats.
74
+ * What the beta.239 Ember & Oak start added: "hand-poured", "handmade"
75
+ * and "by hand" never make "hands" a subject, and a story of the craft
76
+ * asks for the maker at work ("candle making"); a hit carrying a topic
77
+ * the brief never named (a cross, a grave, a flag, a gun, wine, a child,
78
+ * a mannequin) never fills anything; a product, and a collection tile,
79
+ * take only a hit naming its head noun among the first tags, held to the
80
+ * shop's category; and an object whose word is not enough ("melt") is
81
+ * named and searched by its kin ("wax melt", "melt warmer"), never by the
82
+ * category alone. And from the Kiln & Clover portfolio: nudity never fills
83
+ * anything, for any business; a hit naming a rival craft (glass, for a
84
+ * potter) counts only when it names the craft's own words too.
73
85
  * 4. **Never twice.** A photo is placed once per job: not twice on a page,
74
86
  * and not on a page when another page (or another pass, in this process)
75
87
  * of the same job already placed it. The caller hands in what the job's
@@ -83,6 +95,18 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
83
95
  * to its photographer. A copy that fails tries the slot's next accepted
84
96
  * hit.
85
97
  *
98
+ * 6. **Fall back to the craft, never off it** (the beta.241 Saltmarsh Soap
99
+ * Co start, a store "for handmade cold-process soap": its craft read as
100
+ * nothing, its hero searched "shop" and took a florist's bouquet, and four
101
+ * of five products took the starters' laptop desk and living room). A
102
+ * business that is only a place takes its craft from what it is for
103
+ * ("soap shop"); a hero names the craft itself ({@link AiStockSiteTerms.core});
104
+ * a product, after its own searches, tries its head noun anywhere among
105
+ * the tags, its gift wrapper with the craft and the craft's broad
106
+ * searches, and then — a product or a hero only — reuses an on-topic photo
107
+ * the job already shows rather than take a starter. Every slot of a
108
+ * craft's site tries the craft's broad searches last.
109
+ *
86
110
  * A slot nothing acceptable answered — the library down, refusing, or out
87
111
  * of time — is left `null` for the starter photos. A named object is never
88
112
  * given one while the library answers anything of the site's world: the
@@ -98,12 +122,18 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
98
122
  * The unit job input naming the screen drafts the job's other pages were
99
123
  * written to, whose photos a page does not place again (AGL-3660).
100
124
  */ export const AI_STOCK_PHOTO_PAGES_INPUT = 'stockPhotoPages';
125
+ /**
126
+ * The unit job input naming library photos (`media:` references) another
127
+ * part of the job already shows outside its pages — a dataset's records
128
+ * (AGL-3616) — which a page does not place again.
129
+ */ export const AI_STOCK_PHOTO_AVOID_INPUT = 'stockPhotoAvoid';
101
130
  const words = (list)=>new Set(list.trim().split(/\s+/));
102
131
  /** Words that carry no subject. */ const STOPWORDS = words('a an the and or of in on at to for with from by into onto over under near our your their my his her its ' + 'this that these those is are be being was were who whom which where while as about than then very just ' + 'small local best new top quality premium family-owned family owned independent professional ' + 'business company services service page photo picture image showing shows show ' + 'someone people person one two three some many several');
103
132
  /**
104
133
  * Words that describe a picture's look, not what it shows, dropped from a
105
134
  * subject. A word that is as often a noun ("light", "set") is not one.
106
- */ const FILLER = words('beautiful beautifully stunning gorgeous lovely pretty elegant modern contemporary rustic cozy cosy warm cool ' + 'soft bright natural simple minimal minimalist delicate unique special artisan artisanal handmade ' + 'hand-made hand-thrown handthrown hand-crafted handcrafted crafted hand-built handbuilt hand-poured ' + 'hand-painted hand-dyed hand-stitched poured finished styled perfect ' + 'fresh clean calm serene quiet peaceful vibrant colorful colourful sunlit sunny golden glossy matte textured ' + 'close-up closeup up view views detail details detailed shot shots overhead flat lay flatlay flat-lay ' + 'top-down wide angle featuring featured displayed arranged sitting resting placed standing lined ' + 'single pair collection assortment selection group few various array range large tiny big little ' + 'tall short wide narrow everyday classic signature original favorite favourite bestselling best-selling ' + 'small-batch batch limited edition custom bespoke ' + 'white black grey gray beige cream ivory brown tan blue green red pink yellow orange purple speckled ' + 'gentle airy inviting welcoming cheerful happy smiling friendly');
135
+ */ const FILLER = words('beautiful beautifully stunning gorgeous lovely pretty elegant modern contemporary rustic cozy cosy warm cool ' + 'soft bright natural simple minimal minimalist delicate unique special artisan artisanal handmade ' + 'hand-made hand-thrown handthrown hand-crafted handcrafted crafted hand-built handbuilt hand-poured ' + 'hand-painted hand-dyed hand-stitched poured finished styled perfect wheel-thrown wheelthrown thrown ' + 'fresh clean calm serene quiet peaceful vibrant colorful colourful sunlit sunny golden glossy matte textured ' + 'close-up closeup up view views detail details detailed shot shots overhead flat lay flatlay flat-lay ' + 'top-down wide angle featuring featured displayed arranged sitting resting placed standing lined ' + 'single pair collection assortment selection group few various array range large tiny big little ' + 'tall short wide narrow everyday classic signature original favorite favourite bestselling best-selling ' + 'small-batch batch limited edition custom bespoke ' + // How a soap is made, never what it shows: "cold" and "process" named a supermarket fridge (Saltmarsh, 2026-10-10).
136
+ 'cold-process cold-processed hot-process hot-processed melt-and-pour ' + 'white black grey gray beige cream ivory brown tan blue green red pink yellow orange purple speckled ' + 'gentle airy inviting welcoming cheerful happy smiling friendly');
107
137
  /** The nouns that name a place of business, not its craft: "studio" in "ceramics studio". */ const VENUES = words('studio studios shop shops store stores boutique company co workshop gallery atelier agency brand practice ' + 'salon house collective lab market services service firm clinic center centre parlor parlour bar ' + 'class classes lessons school academy');
108
138
  /** The person who does the craft, not the craft: "artist" in "ceramic artist". */ const ROLES = words('artist artists maker makers artisan artisans owner owners founder founders creator creators');
109
139
  /** Words naming what kind of SITE it is, never what it shows: "portfolio" in "a portfolio for a ceramic artist". */ const SITE_KINDS = words('portfolio portfolios website websites site sites blog blogs online web webshop ecommerce e-commerce ' + 'landing personal homepage storefront');
@@ -119,6 +149,374 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
119
149
  * an empty frame on the page (the Juniper Clay hero), a template or a
120
150
  * graphic is not a photo of anything.
121
151
  */ const REJECTED_TAGS = words('mockup mock-up mock blank template templates clipart clip-art illustration vector logo icon ' + 'ai-generated generated render rendering 3d');
152
+ /**
153
+ * Topics a business's photos never carry unless its own words name them
154
+ * (AGL-3660, the beta.239 Ember & Oak start, whose "Gifts for melt fans" tile
155
+ * showed devotional candles with crosses and names). A hit tagged with any
156
+ * word of a topic is rejected unless the site's business type or brief says
157
+ * one of the topic's words or its `allow` words. The last topic is not
158
+ * sensitive but is never the thing: the same start's story band showed bronze
159
+ * mannequin hands tagged "wax".
160
+ */ const SENSITIVE_TOPICS = [
161
+ {
162
+ // Religion.
163
+ words: [
164
+ 'cross',
165
+ 'crosses',
166
+ 'crucifix',
167
+ 'church',
168
+ 'chapel',
169
+ 'cathedral',
170
+ 'jesus',
171
+ 'christ',
172
+ 'christian',
173
+ 'christianity',
174
+ 'god',
175
+ 'bible',
176
+ 'rosary',
177
+ 'prayer',
178
+ 'pray',
179
+ 'praying',
180
+ 'worship',
181
+ 'worships',
182
+ 'religion',
183
+ 'religious',
184
+ 'faith',
185
+ 'islam',
186
+ 'islamic',
187
+ 'muslim',
188
+ 'mosque',
189
+ 'quran',
190
+ 'temple',
191
+ 'buddha',
192
+ 'buddhism',
193
+ 'buddhist',
194
+ 'hindu',
195
+ 'hinduism',
196
+ 'synagogue',
197
+ 'jewish',
198
+ 'judaism',
199
+ 'ecclesiastical',
200
+ 'saint',
201
+ 'altar',
202
+ 'sacrificial',
203
+ 'nativity',
204
+ 'priest',
205
+ 'nun',
206
+ 'monk'
207
+ ],
208
+ allow: [
209
+ 'ministry',
210
+ 'parish',
211
+ 'congregation',
212
+ 'wedding'
213
+ ]
214
+ },
215
+ {
216
+ // Death and mourning.
217
+ words: [
218
+ 'funeral',
219
+ 'memorial',
220
+ 'grave',
221
+ 'gravestone',
222
+ 'cemetery',
223
+ 'tomb',
224
+ 'death',
225
+ 'dead',
226
+ 'grief',
227
+ 'mourning',
228
+ 'condolence',
229
+ 'coffin',
230
+ 'skull'
231
+ ],
232
+ allow: [
233
+ 'hospice',
234
+ 'mortuary',
235
+ 'cremation',
236
+ 'obituary',
237
+ 'halloween'
238
+ ]
239
+ },
240
+ {
241
+ // Politics.
242
+ words: [
243
+ 'political',
244
+ 'politics',
245
+ 'protest',
246
+ 'protester',
247
+ 'demonstration',
248
+ 'rally',
249
+ 'flag',
250
+ 'election',
251
+ 'vote',
252
+ 'voting',
253
+ 'parliament',
254
+ 'government'
255
+ ],
256
+ allow: [
257
+ 'advocacy',
258
+ 'campaign',
259
+ 'candidate',
260
+ 'council',
261
+ 'nonprofit'
262
+ ]
263
+ },
264
+ {
265
+ // Weapons and war.
266
+ words: [
267
+ 'gun',
268
+ 'rifle',
269
+ 'pistol',
270
+ 'weapon',
271
+ 'firearm',
272
+ 'ammunition',
273
+ 'bullet',
274
+ 'sword',
275
+ 'war',
276
+ 'military',
277
+ 'army',
278
+ 'soldier'
279
+ ],
280
+ allow: [
281
+ 'hunting',
282
+ 'archery',
283
+ 'martial',
284
+ 'fencing',
285
+ 'veteran'
286
+ ]
287
+ },
288
+ {
289
+ // Alcohol, for a business that does not serve it.
290
+ words: [
291
+ 'alcohol',
292
+ 'wine',
293
+ 'beer',
294
+ 'whiskey',
295
+ 'whisky',
296
+ 'vodka',
297
+ 'cocktail',
298
+ 'liquor',
299
+ 'rum',
300
+ 'gin',
301
+ 'champagne',
302
+ 'drunk'
303
+ ],
304
+ allow: [
305
+ 'winery',
306
+ 'vineyard',
307
+ 'brewery',
308
+ 'distillery',
309
+ 'pub',
310
+ 'tavern',
311
+ 'taproom',
312
+ 'bartender',
313
+ 'sommelier',
314
+ 'spirits'
315
+ ]
316
+ },
317
+ {
318
+ // Tobacco and drugs.
319
+ words: [
320
+ 'cigarette',
321
+ 'smoking',
322
+ 'tobacco',
323
+ 'cannabis',
324
+ 'marijuana',
325
+ 'drug',
326
+ 'drugs'
327
+ ],
328
+ allow: [
329
+ 'dispensary',
330
+ 'vape',
331
+ 'cigar'
332
+ ]
333
+ },
334
+ {
335
+ // Children, for a business that is not about them.
336
+ words: [
337
+ 'child',
338
+ 'children',
339
+ 'kid',
340
+ 'kids',
341
+ 'baby',
342
+ 'babies',
343
+ 'toddler',
344
+ 'infant'
345
+ ],
346
+ allow: [
347
+ 'nursery',
348
+ 'daycare',
349
+ 'preschool',
350
+ 'kindergarten',
351
+ 'school',
352
+ 'tutor',
353
+ 'tutoring',
354
+ 'parent',
355
+ 'parents',
356
+ 'parenting',
357
+ 'family',
358
+ 'families',
359
+ 'pediatric',
360
+ 'paediatric',
361
+ 'newborn',
362
+ 'maternity'
363
+ ]
364
+ },
365
+ {
366
+ // Nudity, for every business: no word of a brief lets it in (Kiln &
367
+ // Clover, 2026-10-10: a nude torso sculpture filled "About the artist").
368
+ // Never bare "body": a skincare shop's body lotion is its product.
369
+ words: [
370
+ 'nude',
371
+ 'nudes',
372
+ 'nudity',
373
+ 'naked',
374
+ 'torso',
375
+ 'breast',
376
+ 'breasts',
377
+ 'erotic',
378
+ 'sensual',
379
+ 'lingerie',
380
+ 'bikini'
381
+ ],
382
+ allow: [],
383
+ always: true
384
+ },
385
+ {
386
+ // Figures, never the maker: mannequin hands are not a candlemaker's.
387
+ words: [
388
+ 'mannequin',
389
+ 'mannequins',
390
+ 'dummy',
391
+ 'statue',
392
+ 'sculpture',
393
+ 'figurine',
394
+ 'doll',
395
+ 'dolls',
396
+ 'waxwork'
397
+ ],
398
+ allow: [
399
+ 'sculptor',
400
+ 'museum',
401
+ 'fashion',
402
+ 'boutique',
403
+ 'clothing',
404
+ 'apparel',
405
+ 'toy',
406
+ 'toys'
407
+ ]
408
+ }
409
+ ];
410
+ /** Each topic's words and the words that allow it, as stems. */ const SENSITIVE_STEMS = SENSITIVE_TOPICS.map((topic)=>{
411
+ var _topic_allow;
412
+ return {
413
+ words: new Set(topic.words.map(aiStockStem)),
414
+ allow: new Set(topic.always ? [] : [
415
+ ...topic.words,
416
+ ...(_topic_allow = topic.allow) != null ? _topic_allow : []
417
+ ].map(aiStockStem))
418
+ };
419
+ });
420
+ /**
421
+ * Hands are never a picture's subject (AGL-3660): "hand-poured", "handmade"
422
+ * and "by hand" say how a thing was made, and a search for "hands" found
423
+ * bronze mannequin hands for the beta.239 Ember & Oak story band. Each of
424
+ * these is dropped from a picture's words; a hand only stays where it names
425
+ * a thing sold for hands ("hand cream").
426
+ */ const HAND_IDIOMS = [
427
+ /\b(?:made\s+|poured\s+|crafted\s+|finished\s+|done\s+)?by\s+(?:our\s+|my\s+|their\s+)?(?:own\s+)?hands?\b/g,
428
+ /\bwith\s+(?:our|my|their)\s+(?:own\s+)?(?:two\s+)?hands\b/g,
429
+ /\bhand[- ]?(?:poured|made|crafted|thrown|built|painted|dyed|stitched|sewn|knit|knitted|carved|tied|lettered|blown|bound|rolled|dipped|picked|forged|woven|cut|finished|mixed|cast|formed|shaped|pressed|printed|wrapped|selected|blended|turned|glazed)\b/g,
430
+ /\bhands?\b(?!\s+(?:creams?|soaps?|lotions?|wash|towels?|saniti[sz]ers?|salves?|balms?|tools?|saws?|planes?|drills?|mixers?|puppets?|bags?|warmers?))/g
431
+ ];
432
+ /** A text without its hands. */ function withoutHands(text) {
433
+ return HAND_IDIOMS.reduce((out, idiom)=>out.replace(idiom, ' '), text.toLowerCase());
434
+ }
435
+ /** Whether a text says how a thing was made by hand: a maker at work. */ function saysMadeByHand(text) {
436
+ const lower = text.toLowerCase();
437
+ return HAND_IDIOMS.some((idiom)=>{
438
+ idiom.lastIndex = 0;
439
+ const found = idiom.test(lower);
440
+ idiom.lastIndex = 0;
441
+ return found;
442
+ });
443
+ }
444
+ /**
445
+ * The kin of a few objects whose own word is not enough (AGL-3660): a "melt"
446
+ * is any melting thing (the beta.239 "Wax melts" tile showed resin fluid
447
+ * art), so a wax melt names one of these phrases. `within` is the world they
448
+ * are of; `qualifier` the word a search adds; `queries` the searches for the
449
+ * object itself, which stand in for the shop's category alone: a candle is
450
+ * not a wax melt.
451
+ */ const OBJECT_KIN = [
452
+ {
453
+ objects: [
454
+ 'melt',
455
+ 'tart',
456
+ 'cube'
457
+ ],
458
+ within: [
459
+ 'wax',
460
+ 'candle',
461
+ 'soy'
462
+ ],
463
+ qualifier: 'wax',
464
+ kin: [
465
+ 'wax melt',
466
+ 'wax melts',
467
+ 'wax cube',
468
+ 'wax cubes',
469
+ 'wax tart',
470
+ 'wax tarts',
471
+ 'melt warmer',
472
+ 'wax warmer',
473
+ 'tart warmer',
474
+ 'oil burner',
475
+ 'oil warmer',
476
+ 'aroma burner',
477
+ 'scent warmer',
478
+ 'fragrance warmer'
479
+ ],
480
+ queries: [
481
+ 'wax melts',
482
+ 'wax melt warmer',
483
+ 'scented wax cubes'
484
+ ]
485
+ },
486
+ {
487
+ // A "bar" is a pub's before it is a soap's (the Saltmarsh "cold-process
488
+ // soap bar" search answered a pint of beer second): a soap bar is named
489
+ // by soap, and searched by it.
490
+ objects: [
491
+ 'bar'
492
+ ],
493
+ within: [
494
+ 'soap',
495
+ 'soapmaker',
496
+ 'skincare'
497
+ ],
498
+ qualifier: 'soap',
499
+ kin: [
500
+ 'soap bar',
501
+ 'soap bars',
502
+ 'bar of soap',
503
+ 'bars of soap',
504
+ 'soap',
505
+ 'soaps',
506
+ 'handmade soap',
507
+ 'soap block',
508
+ 'soap cubes'
509
+ ],
510
+ queries: [
511
+ 'soap bars',
512
+ 'handmade soap bar'
513
+ ]
514
+ }
515
+ ];
516
+ /** An object's kin where the site or the subject is of its world, else `null`. */ function objectKin(object, world) {
517
+ var _OBJECT_KIN_find;
518
+ return (_OBJECT_KIN_find = OBJECT_KIN.find((entry)=>entry.objects.some((word)=>aiStockStem(word) === object) && entry.within.some((word)=>world.includes(aiStockStem(word))))) != null ? _OBJECT_KIN_find : null;
519
+ }
122
520
  /**
123
521
  * The kin of a few common crafts: the words a photo of that world is
124
522
  * tagged with, and the broad searches that find it. A craft not listed
@@ -150,6 +548,33 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
150
548
  broad: [
151
549
  'handmade ceramics',
152
550
  'pottery'
551
+ ],
552
+ rivals: {
553
+ words: [
554
+ 'glass',
555
+ 'glassware',
556
+ 'glasswork',
557
+ 'glassblowing',
558
+ 'blown',
559
+ 'stained',
560
+ 'murano',
561
+ 'crystal'
562
+ ],
563
+ core: [
564
+ 'ceramic',
565
+ 'ceramics',
566
+ 'pottery',
567
+ 'potter',
568
+ 'stoneware',
569
+ 'porcelain',
570
+ 'earthenware',
571
+ 'clay',
572
+ 'terracotta'
573
+ ]
574
+ },
575
+ making: [
576
+ 'pottery wheel',
577
+ 'potter at work'
153
578
  ]
154
579
  },
155
580
  {
@@ -171,6 +596,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
171
596
  broad: [
172
597
  'handmade candles',
173
598
  'scented candles'
599
+ ],
600
+ making: [
601
+ 'candle making',
602
+ 'pouring wax'
174
603
  ]
175
604
  },
176
605
  {
@@ -196,6 +625,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
196
625
  broad: [
197
626
  'handmade jewelry',
198
627
  'jewelry'
628
+ ],
629
+ making: [
630
+ 'jewelry making',
631
+ 'jeweler at work'
199
632
  ]
200
633
  },
201
634
  {
@@ -204,19 +637,53 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
204
637
  'soapmaker',
205
638
  'skincare'
206
639
  ],
640
+ // Never "natural": a florist's bouquet tagged "natural" was the
641
+ // Saltmarsh Soap Co hero (2026-10-10).
207
642
  kin: [
208
643
  'soap',
209
644
  'skincare',
210
645
  'lotion',
646
+ 'cream',
211
647
  'bath',
212
648
  'spa',
213
- 'natural',
214
649
  'cosmetic'
215
650
  ],
216
651
  broad: [
217
652
  'handmade soap',
218
653
  'natural skincare'
219
- ]
654
+ ],
655
+ // "Soap making" is answered with soap bubbles; a bar is what a soapmaker makes.
656
+ making: [
657
+ 'soap making',
658
+ 'handmade soap'
659
+ ],
660
+ // Soap bubbles, a dispenser and detergent are soap, never a soapmaker's:
661
+ // the same start's story band showed bubbles in flight.
662
+ rivals: {
663
+ words: [
664
+ 'bubble',
665
+ 'bubbles',
666
+ 'soapy',
667
+ 'dispenser',
668
+ 'detergent',
669
+ 'dishwashing',
670
+ 'laundry'
671
+ ],
672
+ core: [
673
+ 'bar',
674
+ 'bars',
675
+ 'handmade',
676
+ 'homemade',
677
+ 'loaf',
678
+ 'lye',
679
+ 'glycerin',
680
+ 'skincare',
681
+ 'lotion',
682
+ 'cosmetic',
683
+ 'spa',
684
+ 'artisan'
685
+ ]
686
+ }
220
687
  },
221
688
  {
222
689
  match: [
@@ -242,6 +709,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
242
709
  broad: [
243
710
  'woodworking',
244
711
  'wooden furniture'
712
+ ],
713
+ making: [
714
+ 'woodworking workshop',
715
+ 'carpenter at work'
245
716
  ]
246
717
  },
247
718
  {
@@ -266,6 +737,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
266
737
  broad: [
267
738
  'bakery',
268
739
  'fresh bread'
740
+ ],
741
+ making: [
742
+ 'baker kneading dough',
743
+ 'baking bread'
269
744
  ]
270
745
  },
271
746
  {
@@ -288,6 +763,9 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
288
763
  broad: [
289
764
  'coffee shop',
290
765
  'coffee'
766
+ ],
767
+ making: [
768
+ 'barista making coffee'
291
769
  ]
292
770
  },
293
771
  {
@@ -307,6 +785,9 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
307
785
  broad: [
308
786
  'florist',
309
787
  'flower bouquet'
788
+ ],
789
+ making: [
790
+ 'florist arranging flowers'
310
791
  ]
311
792
  },
312
793
  {
@@ -336,6 +817,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
336
817
  broad: [
337
818
  'handmade textiles',
338
819
  'yarn'
820
+ ],
821
+ making: [
822
+ 'knitting',
823
+ 'weaving loom'
339
824
  ]
340
825
  },
341
826
  {
@@ -354,6 +839,9 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
354
839
  broad: [
355
840
  'leather craft',
356
841
  'leather goods'
842
+ ],
843
+ making: [
844
+ 'leather crafting'
357
845
  ]
358
846
  }
359
847
  ];
@@ -422,10 +910,11 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
422
910
  return craft ? aiStockStem(craft) : '';
423
911
  }
424
912
  /** The site's terms, read from its business type. */ export function aiStockSiteTerms(businessType) {
425
- const business = aiStockBusinessWords(businessType);
426
- const craft = aiStockCraftWords(business);
913
+ let business = aiStockBusinessWords(businessType);
914
+ let craft = aiStockCraftWords(business);
427
915
  const domain = new Set();
428
916
  /** The things it makes, by their head nouns. */ const made = new Set();
917
+ /** The last word of the first clause that names the world: "soap" of "for handmade cold-process soap". */ let firstWorld = '';
429
918
  let named = false;
430
919
  let making = false;
431
920
  for (const { opener, words: clause } of businessClauses(businessType)){
@@ -440,18 +929,44 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
440
929
  // What it makes names its world by what the things are made of, never
441
930
  // the things: "stoneware" of "stoneware bowls", "soy" of "soy candles".
442
931
  // A bowl is not a ceramic world; a robin's food bowl is not pottery.
443
- const world = making ? kept.slice(0, -1) : kept;
932
+ // A set or a gift is never a world: "gift" of "gift sets" is not one.
933
+ const world = (making ? kept.slice(0, -1) : kept).filter((word)=>!WRAPPERS.has(word));
444
934
  for (const word of world)for (const stem of stemList(word))domain.add(stem);
935
+ const last = world[world.length - 1];
936
+ if (!firstWorld && last) firstWorld = aiStockStem(last);
445
937
  if (making) for (const word of kept.slice(-1))for (const stem of stemList(word))made.add(stem);
446
938
  }
447
- if (craft) domain.add(craft);
448
939
  // A business named only by what it sells ("a shop selling pottery") is of that world.
449
940
  if (!domain.size) for (const stem of made)domain.add(stem);
941
+ const groups = CRAFT_KIN.filter((group)=>group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)) || aiStockStem(word) === craft));
942
+ // A business that is only a place ("an online shop for handmade
943
+ // cold-process soap") takes its craft from what it is for: the Saltmarsh
944
+ // Soap Co start (2026-10-10) read its craft as nothing, so its hero searched
945
+ // "shop" (a florist's bouquet) and no product was held to soap.
946
+ if (!craft) {
947
+ var _groups_;
948
+ const listed = (_groups_ = groups[0]) == null ? void 0 : _groups_.match.map(aiStockStem).find((stem)=>domain.has(stem) || made.has(stem));
949
+ craft = listed || firstWorld || [
950
+ ...made
951
+ ][0] || '';
952
+ if (craft && !stemList(business).includes(craft)) business = `${craft} ${business}`.trim();
953
+ }
954
+ if (craft) domain.add(craft);
450
955
  const broad = [];
451
- for (const group of CRAFT_KIN){
452
- if (!group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)))) continue;
956
+ const makingSearches = [];
957
+ const rivals = [];
958
+ const core = new Set(craft ? [
959
+ craft
960
+ ] : []);
961
+ for (const group of groups){
962
+ for (const word of group.match)core.add(aiStockStem(word));
453
963
  for (const word of group.kin)domain.add(aiStockStem(word));
454
964
  broad.push(...group.broad);
965
+ makingSearches.push(...group.making);
966
+ if (group.rivals) rivals.push({
967
+ words: group.rivals.words.map(aiStockStem),
968
+ core: group.rivals.core.map(aiStockStem)
969
+ });
455
970
  }
456
971
  if (!broad.length) broad.push(business, craft);
457
972
  return {
@@ -462,6 +977,16 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
462
977
  ].filter((stem)=>stem.length > 1),
463
978
  broad: [
464
979
  ...new Set(broad.map((query)=>query.trim()).filter(Boolean))
980
+ ],
981
+ making: [
982
+ ...new Set(makingSearches)
983
+ ],
984
+ named: [
985
+ ...new Set(stemList(businessType))
986
+ ],
987
+ rivals,
988
+ core: [
989
+ ...core
465
990
  ]
466
991
  };
467
992
  }
@@ -474,11 +999,12 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
474
999
  * preposition starts the setting, filler dropped, its last three words (the
475
1000
  * head noun is the last) — else its section's name.
476
1001
  */ export function aiStockSubjectWords(alt, sectionName) {
477
- for (const clause of alt.toLowerCase().split(CLAUSE_BREAK)){
1002
+ // "Hand-poured", "by hand" and "hands" say how it was made, never what it shows.
1003
+ for (const clause of withoutHands(alt).split(CLAUSE_BREAK)){
478
1004
  const found = subjectOf(contentWords(clause != null ? clause : ''));
479
1005
  if (found.length) return found.slice(-3).join(' ');
480
1006
  }
481
- return subjectOf(contentWords(sectionName)).slice(0, 2).join(' ');
1007
+ return subjectOf(contentWords(withoutHands(sectionName))).slice(0, 2).join(' ');
482
1008
  }
483
1009
  /**
484
1010
  * The thing a subject shows, one singular word: its last word that is not
@@ -499,16 +1025,54 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
499
1025
  if (aspect < 0.87) return 'vertical';
500
1026
  return 'any';
501
1027
  }
1028
+ /**
1029
+ * A slot's part (AGL-3660): a tile of a collections band is product-like,
1030
+ * held to the shop's category and its own object; a picture of a story of
1031
+ * the craft, or of a thing made by hand, shows the maker at work.
1032
+ */ export function aiStockSlotPart(slot, sectionName) {
1033
+ if (slot.role === 'hero') return undefined;
1034
+ if (slot.role === 'gallery' && aiStorefrontSays('collections', sectionName)) return 'collection';
1035
+ if (aiStorefrontSays('story', sectionName) || saysMadeByHand(slot.alt) || saysMadeByHand(sectionName)) return 'story';
1036
+ return undefined;
1037
+ }
1038
+ /** An object's kin phrases as stems, or none. */ const kinStems = (entry)=>entry ? [
1039
+ ...new Set(entry.kin.map((phrase)=>stemList(phrase).join(' ')))
1040
+ ] : [];
1041
+ /** The wrapper words a gift is named by, searched with the craft: "soap gift box". */ const GIFT_WRAPPERS = words('gift gifts box boxes basket baskets hamper hampers');
502
1042
  /**
503
1043
  * A product's searches (AGL-3676), most specific first: each of its subjects
504
1044
  * — its name's noun phrase, then its photo's — with the shop's category
505
- * where the phrase does not already say it ("candle gift box"), then the
506
- * category alone, a plain photo of what the shop sells. Every one must find a
507
- * hit naming the category, so no product is ever filled with a lifestyle shot
508
- * of something else: the beta.237 Willow Wick start put a laptop and roses
509
- * under "Candle Wick Trimmer" and an antique tea set under "Candle Gift Set".
510
- */ export function aiStockProductSearches(slot, craft) {
511
- var _ref;
1045
+ * where the phrase does not already name the shop's world ("candle gift
1046
+ * box"), then the category alone, a plain photo of what the shop sells. Every
1047
+ * one must find a hit naming the category — or the product's own word of the
1048
+ * shop's world, "lotion" in a soap shop — so no product is ever filled with a
1049
+ * lifestyle shot of something else: the beta.237 Willow Wick start put a
1050
+ * laptop and roses under "Candle Wick Trimmer" and an antique tea set under
1051
+ * "Candle Gift Set".
1052
+ *
1053
+ * And each must find a hit naming the product's own head noun among its
1054
+ * first tags (AGL-3660): the beta.239 Ember & Oak start put a bed with a book,
1055
+ * a mug and a camera (its candle a last tag) under "Hand-Poured Soy Candle".
1056
+ * An object whose own word is not enough ({@link OBJECT_KIN}) is searched by
1057
+ * its kin instead of the category alone, and named by them: a single taper
1058
+ * candle filled "Wax Melt Gift Set".
1059
+ *
1060
+ * Then, before a product is ever given a photo of something else, it falls
1061
+ * back to its own craft (AGL-3660). The Saltmarsh Soap Co start (2026-10-10)
1062
+ * found no hit naming "bar" or "box" among the first tags, and four of its
1063
+ * five products took the starter photos — a laptop and roses, a living room
1064
+ * of plants. So, in order:
1065
+ *
1066
+ * 1. The same searches again, the head noun named anywhere among the tags
1067
+ * (no extra search: the page remembers its answers), and a gift's own
1068
+ * wrapper with the craft ("soap gift box").
1069
+ * 2. The site's broad searches ("handmade soap", "natural skincare"), any hit
1070
+ * naming the craft itself ({@link AiStockSiteTerms.core}).
1071
+ *
1072
+ * The source then reuses an on-topic photo another slot of the job already
1073
+ * shows before it gives up ({@link aiLayoutStockPhotoSource}).
1074
+ */ export function aiStockProductSearches(slot, craft, domain = [], fallback = {}) {
1075
+ var _ref, _subjects_, _fallback_broad, _fallback_core;
512
1076
  var _slot_product;
513
1077
  const orientation = aiStockOrientation(slot.aspect);
514
1078
  const size = orientation === 'vertical' ? {
@@ -517,38 +1081,131 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
517
1081
  minWidth: 900
518
1082
  };
519
1083
  const craftStems = stemList(craft);
1084
+ const world = new Set([
1085
+ ...domain,
1086
+ ...craftStems
1087
+ ]);
1088
+ const subjects = ((_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []).map((raw)=>aiStockSubjectWords(raw, '')).filter(Boolean);
1089
+ // The product's head noun is its name's: "candle" of "Hand-Poured Soy Candle".
1090
+ const own = (_subjects_ = subjects[0]) != null ? _subjects_ : '';
1091
+ const object = aiStockObjectWord(own);
1092
+ const kin = object ? objectKin(object, [
1093
+ ...craftStems,
1094
+ ...domain,
1095
+ ...stemList(subjects.join(' '))
1096
+ ]) : null;
1097
+ const kinPhrases = kinStems(kin);
1098
+ // A product of the shop's world beside its category is held to either:
1099
+ // a soap shop's body lotion names "lotion", never "soap".
1100
+ const ownWorld = stemList(own).filter((stem)=>world.has(stem) && !WRAPPERS.has(stem) && !craftStems.includes(stem));
1101
+ const category = craft && ownWorld.length ? [
1102
+ ...craftStems,
1103
+ ...ownWorld
1104
+ ] : [];
520
1105
  const queries = [];
521
- for (const raw of (_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []){
522
- const subject = aiStockSubjectWords(raw, '');
523
- if (!subject) continue;
524
- const says = craftStems.every((stem)=>stemList(subject).includes(stem));
1106
+ for (const subject of subjects){
1107
+ const stems = stemList(subject);
1108
+ const query = kin ? stems.includes(aiStockStem(kin.qualifier)) ? subject : `${kin.qualifier} ${subject}` : craft && !stems.some((stem)=>world.has(stem)) ? `${craft} ${subject}` : subject;
525
1109
  queries.push({
526
- query: craft && !says ? `${craft} ${subject}` : subject,
527
- subject
1110
+ query,
1111
+ subject,
1112
+ object,
1113
+ lead: true
528
1114
  });
529
1115
  }
530
- if (craft) queries.push({
1116
+ // The category alone stands in only for a thing that is no more than of
1117
+ // it; a thing that is its own word of the shop's world is searched by that
1118
+ // word ("lotion"). Its hits are still scored by the product's own words: a
1119
+ // soy candle over a taper.
1120
+ if (kin) for (const query of kin.queries)queries.push({
1121
+ query,
1122
+ subject: own || query,
1123
+ object,
1124
+ lead: true
1125
+ });
1126
+ else if (object && ownWorld.includes(object)) queries.push({
1127
+ query: object,
1128
+ subject: own,
1129
+ object,
1130
+ lead: true
1131
+ });
1132
+ else if (craft) queries.push({
531
1133
  query: craft,
532
- subject: craft
1134
+ subject: own || craft,
1135
+ object: craft,
1136
+ lead: true
1137
+ });
1138
+ // 1. The head noun anywhere among the tags, by the same searches.
1139
+ for (const entry of [
1140
+ ...queries
1141
+ ])queries.push(_extends({}, entry, {
1142
+ lead: false
1143
+ }));
1144
+ // A gift is its craft's: "bath gift box" asks for a "soap gift box".
1145
+ const wrapped = own.split(/\s+/).filter((word)=>WRAPPERS.has(word) && aiStockStem(word) !== object);
1146
+ const gift = wrapped.find((word)=>GIFT_WRAPPERS.has(word));
1147
+ if (craft && gift && !kin) queries.push({
1148
+ query: `${craft} ${wrapped.join(' ')}`,
1149
+ subject: own,
1150
+ object: aiStockStem(gift),
1151
+ lead: false
533
1152
  });
1153
+ // 2. The craft's own broad searches, any hit naming the craft.
1154
+ for (const query of (_fallback_broad = fallback.broad) != null ? _fallback_broad : [])queries.push({
1155
+ query,
1156
+ subject: own,
1157
+ object: '',
1158
+ lead: false,
1159
+ broad: true
1160
+ });
1161
+ const core = [
1162
+ ...new Set([
1163
+ ...craftStems,
1164
+ ...(_fallback_core = fallback.core) != null ? _fallback_core : []
1165
+ ])
1166
+ ];
534
1167
  const seen = new Set();
535
1168
  const searches = [];
536
1169
  for (const entry of queries){
537
1170
  const query = clip(entry.query);
538
- if (!query || seen.has(query)) continue;
539
- seen.add(query);
1171
+ const key = `${query}|${entry.lead}|${entry.object}|${entry.broad === true}`;
1172
+ if (!query || seen.has(key)) continue;
1173
+ seen.add(key);
1174
+ if (entry.broad) {
1175
+ searches.push(_extends({
1176
+ query,
1177
+ orientation
1178
+ }, size, {
1179
+ broad: true,
1180
+ subject: entry.subject
1181
+ }, core.length ? {
1182
+ category: core
1183
+ } : {}));
1184
+ continue;
1185
+ }
540
1186
  searches.push(_extends({
541
1187
  query,
542
1188
  orientation
543
1189
  }, size, {
544
1190
  subject: entry.subject
545
- }, craft ? {
1191
+ }, entry.lead ? {
1192
+ lead: true
1193
+ } : {
1194
+ leadWorld: true
1195
+ }, entry.object ? {
1196
+ object: entry.object
1197
+ } : {}, kinPhrases.length && entry.object === object ? {
1198
+ kin: kinPhrases
1199
+ } : {}, craft ? {
546
1200
  requires: craft
1201
+ } : {}, category.length ? {
1202
+ category
547
1203
  } : {}));
548
1204
  }
549
1205
  return searches;
550
1206
  }
551
- /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, terms, subject) {
1207
+ /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, terms, subject, part) {
1208
+ var _terms_core, _terms_making;
552
1209
  const orientation = aiStockOrientation(slot.aspect);
553
1210
  const size = slot.role === 'hero' ? {
554
1211
  minWidth: 1600
@@ -560,13 +1217,50 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
560
1217
  const domain = new Set(terms.domain);
561
1218
  const object = aiStockObjectWord(subject);
562
1219
  const subjectStems = stemList(subject);
1220
+ const kin = object ? objectKin(object, [
1221
+ ...terms.domain,
1222
+ ...subjectStems
1223
+ ]) : null;
1224
+ const kinPhrases = kinStems(kin);
563
1225
  // The craft qualifies a subject that does not already name the site's world.
564
1226
  const namesWorld = subjectStems.some((stem)=>domain.has(stem));
1227
+ // A tile of the shop's world beside its category is held to either: a soap
1228
+ // shop's "Body care" tile names "body", never "soap" (AGL-3660).
1229
+ const ownWorld = subjectStems.filter((stem)=>domain.has(stem) && !WRAPPERS.has(stem) && stem !== terms.craft);
1230
+ // A hero of a craft names the craft itself: the Saltmarsh Soap Co hero
1231
+ // (2026-10-10) was a florist's bouquet tagged "natural" (AGL-3660).
1232
+ const core = (_terms_core = terms.core) != null ? _terms_core : [];
565
1233
  const qualify = (phrase)=>terms.craft && !namesWorld && !stemList(phrase).includes(terms.craft) ? `${terms.craft} ${phrase}` : phrase;
566
- const broad = terms.broad.map((query)=>({
1234
+ // A collection tile is product-like: held to the shop's category and its
1235
+ // own object among the first tags, and searched by its object's kin, never
1236
+ // the category's world alone (AGL-3660).
1237
+ const collection = part === 'collection' && Boolean(terms.craft);
1238
+ const broad = kin && collection ? [] : terms.broad.map((query)=>({
567
1239
  query,
568
- broad: true
1240
+ broad: true,
1241
+ category: collection
1242
+ }));
1243
+ // The maker at work, held to the craft: never "hands" (AGL-3660).
1244
+ const making = ((_terms_making = terms.making) != null ? _terms_making : []).map((query)=>({
1245
+ query,
1246
+ broad: true,
1247
+ category: true
569
1248
  }));
1249
+ const thing = subject ? [
1250
+ {
1251
+ query: qualify(subject),
1252
+ held: true
1253
+ },
1254
+ ...kin ? kin.queries.map((query)=>({
1255
+ query,
1256
+ held: true
1257
+ })) : [
1258
+ {
1259
+ query: qualify(object),
1260
+ held: true
1261
+ }
1262
+ ]
1263
+ ] : [];
570
1264
  const queries = slot.role === 'hero' ? [
571
1265
  {
572
1266
  query: terms.business,
@@ -574,6 +1268,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
574
1268
  },
575
1269
  ...broad
576
1270
  ] : slot.role === 'about' ? [
1271
+ ...part === 'story' ? making : [],
577
1272
  {
578
1273
  query: `${terms.business} ${subject}`,
579
1274
  broad: true,
@@ -584,16 +1279,13 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
584
1279
  broad: true,
585
1280
  people: true
586
1281
  },
1282
+ ...part === 'story' ? [] : making,
587
1283
  ...broad
588
- ] : subject ? [
589
- {
590
- query: qualify(subject)
591
- },
592
- {
593
- query: qualify(object)
594
- },
1284
+ ] : [
1285
+ ...part === 'story' ? making : [],
1286
+ ...thing,
595
1287
  ...broad
596
- ] : broad;
1288
+ ];
597
1289
  const seen = new Set();
598
1290
  const searches = [];
599
1291
  for (const raw of queries){
@@ -601,6 +1293,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
601
1293
  const key = `${query}|${raw.people === true}`;
602
1294
  if (!query || seen.has(key)) continue;
603
1295
  seen.add(key);
1296
+ const held = raw.held === true && (collection || kinPhrases.length > 0);
604
1297
  searches.push(_extends({
605
1298
  query,
606
1299
  orientation
@@ -608,6 +1301,19 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
608
1301
  people: true
609
1302
  } : {}, raw.broad ? {
610
1303
  broad: true
1304
+ } : {}, held && kinPhrases.length ? {
1305
+ kin: kinPhrases
1306
+ } : {}, held && collection ? {
1307
+ lead: true
1308
+ } : {}, terms.craft && (raw.category || raw.held && collection) ? _extends({
1309
+ requires: terms.craft
1310
+ }, ownWorld.length ? {
1311
+ category: [
1312
+ terms.craft,
1313
+ ...ownWorld
1314
+ ]
1315
+ } : {}) : {}, slot.role === 'hero' && core.length ? {
1316
+ category: core
611
1317
  } : {}));
612
1318
  }
613
1319
  return searches;
@@ -622,7 +1328,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
622
1328
  }
623
1329
  }
624
1330
  /** A hit's words: the first few (what it is OF), every one, and each tag's phrase. */ function hitWords(photo) {
625
- var _photo_tags, _photo_alt, _photo_alt1;
1331
+ var _photo_tags, _photo_alt, _photo_alt1, _photo_alt2;
626
1332
  const slug = pageSlug(photo.pageUrl);
627
1333
  const tags = ((_photo_tags = photo.tags) != null ? _photo_tags : []).map((tag)=>tag.toLowerCase().trim()).filter(Boolean);
628
1334
  const lead = new Set(stemList([
@@ -639,17 +1345,42 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
639
1345
  stemList((_photo_alt1 = photo.alt) != null ? _photo_alt1 : '').join(' '),
640
1346
  stemList(slug).join(' ')
641
1347
  ];
1348
+ const leadPhrases = [
1349
+ ...tags.slice(0, 3).map((tag)=>stemList(tag).join(' ')),
1350
+ stemList(slug).join(' ')
1351
+ ];
642
1352
  const rejected = tags.some((tag)=>REJECTED_TAGS.has(tag) || tag.split(/\s+/).some((word)=>REJECTED_TAGS.has(word)));
1353
+ // Whole words only, a hyphenated one kept whole: "cross-stitch" is not a cross.
1354
+ const words = new Set([
1355
+ ...tags,
1356
+ ((_photo_alt2 = photo.alt) != null ? _photo_alt2 : '').toLowerCase()
1357
+ ].join(' ').split(/[^a-zÀ-ɏ-]+/).filter((word)=>word.length > 1).map(aiStockStem));
643
1358
  return {
644
1359
  lead,
645
1360
  all,
646
1361
  phrases,
647
- rejected
1362
+ leadPhrases,
1363
+ rejected,
1364
+ words
648
1365
  };
649
1366
  }
1367
+ /** Whether a phrase is among a hit's phrases, word for word. */ const among = (phrases, phrase)=>phrases.some((found)=>` ${found} `.includes(` ${phrase} `));
1368
+ /**
1369
+ * Whether a hit carries a topic its site never named ({@link SENSITIVE_TOPICS}):
1370
+ * a cross, a grave, a flag, a gun, a glass of wine, a child, a mannequin.
1371
+ */ export function aiStockSensitive(photo, named = []) {
1372
+ const { words: found } = hitWords(photo);
1373
+ const site = new Set(named);
1374
+ return SENSITIVE_STEMS.some((topic)=>[
1375
+ ...topic.words
1376
+ ].some((word)=>found.has(word)) && ![
1377
+ ...topic.allow
1378
+ ].some((word)=>site.has(word)));
1379
+ }
650
1380
  /**
651
1381
  * How well a hit shows what a slot needs (AGL-3660), or 0 when it must not
652
- * fill it: a mockup or a blank never; a picture of a thing only when the hit
1382
+ * fill it: a mockup or a blank never; a topic the site never named never
1383
+ * ({@link aiStockSensitive}); a picture of a thing only when the hit
653
1384
  * names its object AND either the site's world or the subject's own
654
1385
  * two-word phrase ("bud vase", "wick trimmer"); anything else only when it
655
1386
  * names the site's world (when the site names one). Among the accepted, the
@@ -657,25 +1388,48 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
657
1388
  * the subject's two-word phrase 3, each of its other words 1, and the
658
1389
  * site's world 1, plus a quarter when it is among the first tags.
659
1390
  */ export function aiStockRelevance(photo, judgement) {
660
- var _judgement_requires, _judgement_subject, _judgement_domain;
1391
+ var _judgement_rivals, _judgement_kin, _judgement_requires, _judgement_category, _judgement_subject, _judgement_object, _judgement_domain;
661
1392
  const hit = hitWords(photo);
662
1393
  if (hit.rejected) return 0;
1394
+ if (aiStockSensitive(photo, judgement.named)) return 0;
1395
+ // Glass art is not pottery: a rival craft's photo names this craft's own words.
1396
+ for (const rival of (_judgement_rivals = judgement.rivals) != null ? _judgement_rivals : [])if (rival.words.some((word)=>hit.all.has(word)) && !rival.core.some((word)=>hit.all.has(word))) return 0;
1397
+ const kin = (_judgement_kin = judgement.kin) != null ? _judgement_kin : [];
1398
+ const kinNamed = kin.some((phrase)=>among(hit.phrases, phrase));
1399
+ const kinLead = kin.some((phrase)=>among(hit.leadPhrases, phrase));
663
1400
  const required = stemList((_judgement_requires = judgement.requires) != null ? _judgement_requires : '');
664
- if (required.some((stem)=>!hit.all.has(stem))) return 0;
1401
+ const category = ((_judgement_category = judgement.category) != null ? _judgement_category : []).flatMap(stemList);
1402
+ const anyOf = category.length ? [
1403
+ ...new Set([
1404
+ ...required,
1405
+ ...category
1406
+ ])
1407
+ ] : [];
1408
+ const demanded = required.length > 0 || anyOf.length > 0;
1409
+ const meets = anyOf.length ? anyOf.some((stem)=>hit.all.has(stem)) : required.every((stem)=>hit.all.has(stem));
1410
+ // An object's kin stands for the category: a wax melt is the candle shop's.
1411
+ if (demanded && !kinNamed && !meets) return 0;
665
1412
  const subject = stemList((_judgement_subject = judgement.subject) != null ? _judgement_subject : '');
666
- const object = judgement.subject ? aiStockObjectWord(judgement.subject) : '';
1413
+ const object = (_judgement_object = judgement.object) != null ? _judgement_object : judgement.subject ? aiStockObjectWord(judgement.subject) : '';
667
1414
  const domain = ((_judgement_domain = judgement.domain) != null ? _judgement_domain : []).filter((stem)=>stem !== object);
668
1415
  // A hit naming the word it must name is of the site's world: a product's
669
1416
  // category is that world even where it is the product's own object.
670
- const world = required.length > 0 || domain.some((stem)=>hit.all.has(stem));
671
- const worldLead = required.length > 0 && required.every((stem)=>hit.lead.has(stem)) || domain.some((stem)=>hit.lead.has(stem));
1417
+ const world = demanded || kinNamed || domain.some((stem)=>hit.all.has(stem));
1418
+ const worldLead = (anyOf.length ? anyOf.some((stem)=>hit.lead.has(stem)) : required.length > 0 && required.every((stem)=>hit.lead.has(stem))) || kinLead || domain.some((stem)=>hit.lead.has(stem));
1419
+ if (judgement.leadWorld && !worldLead) return 0;
672
1420
  const pair = subject.length >= 2 ? subject.slice(-2).join(' ') : '';
673
- const phrased = Boolean(pair) && hit.phrases.some((phrase)=>` ${phrase} `.includes(` ${pair} `));
1421
+ const phrased = Boolean(pair) && among(hit.phrases, pair);
674
1422
  let score = 0;
675
1423
  if (judgement.strict && object) {
676
- if (!hit.all.has(object)) return 0;
1424
+ // An object with kin is named only by them: "melt" alone is resin art.
1425
+ const named = kin.length ? kinNamed : hit.all.has(object);
1426
+ if (!named) return 0;
1427
+ const leading = kin.length ? kinLead : hit.lead.has(object);
1428
+ // A lifestyle scene whose object is a last tag is not a photo OF it.
1429
+ if (judgement.lead && !leading) return 0;
677
1430
  if (domain.length && !world && !phrased) return 0;
678
- score += hit.lead.has(object) ? 4 : 2;
1431
+ score += leading ? 4 : 2;
1432
+ if (kinNamed) score += 3;
679
1433
  } else if (domain.length) {
680
1434
  if (!world) return 0;
681
1435
  }
@@ -793,6 +1547,11 @@ const EXTENSIONS = {
793
1547
  delete request.broad;
794
1548
  delete request.subject;
795
1549
  delete request.requires;
1550
+ delete request.object;
1551
+ delete request.kin;
1552
+ delete request.lead;
1553
+ delete request.leadWorld;
1554
+ delete request.category;
796
1555
  const key = JSON.stringify([
797
1556
  request.query,
798
1557
  request.orientation,
@@ -811,7 +1570,7 @@ const EXTENSIONS = {
811
1570
  answers.set(key, asked);
812
1571
  return asked;
813
1572
  };
814
- /** The photo placed in the site's library, a copy it holds or a new one; `null` when it cannot be placed. */ const keep = async (photo, slot, query)=>{
1573
+ /** The photo placed in the site's library, a copy it holds or a new one; `null` when it cannot be placed. */ const keep = async (photo, slot, query, repeat = false)=>{
815
1574
  const sourceKey = stockPhotoSourceKey(photo);
816
1575
  try {
817
1576
  var _EXTENSIONS_bytes_contentType;
@@ -822,7 +1581,7 @@ const EXTENSIONS = {
822
1581
  if (kept) {
823
1582
  var _kept_width, _kept_height;
824
1583
  // Held because a slot of this page or another page of the job shows it.
825
- if (placedSrcs.has(kept.src) || elsewhere(kept.src)) return null;
1584
+ if (!repeat && (placedSrcs.has(kept.src) || elsewhere(kept.src))) return null;
826
1585
  remember(sourceKey, kept.src);
827
1586
  return {
828
1587
  src: kept.src,
@@ -885,6 +1644,50 @@ const EXTENSIONS = {
885
1644
  }
886
1645
  return null;
887
1646
  };
1647
+ /** How a search's hits are judged for a slot. */ const judgementFor = (slot, request, subject)=>{
1648
+ var _request_subject;
1649
+ return _extends({
1650
+ subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
1651
+ domain: terms.domain,
1652
+ strict: slot.role === 'gallery' && !request.broad || !!request.requires,
1653
+ named: terms.named,
1654
+ rivals: terms.rivals
1655
+ }, request.requires ? {
1656
+ requires: request.requires
1657
+ } : {}, request.category ? {
1658
+ category: request.category
1659
+ } : {}, request.object ? {
1660
+ object: request.object
1661
+ } : {}, request.kin ? {
1662
+ kin: request.kin
1663
+ } : {}, request.lead ? {
1664
+ lead: true
1665
+ } : {}, request.leadWorld ? {
1666
+ leadWorld: true
1667
+ } : {});
1668
+ };
1669
+ /**
1670
+ * The craft's own photos, the last thing a slot of a craft's site is
1671
+ * filled from (AGL-3660): its broad searches, any hit naming the craft
1672
+ * itself. Never a desk, a laptop or a living room for a soap shop.
1673
+ */ const craftSearches = (slot)=>{
1674
+ if (!terms.core.length) return [];
1675
+ const orientation = aiStockOrientation(slot.aspect);
1676
+ const size = slot.role === 'hero' ? {
1677
+ minWidth: 1600
1678
+ } : orientation === 'vertical' ? {
1679
+ minHeight: 900
1680
+ } : {
1681
+ minWidth: 900
1682
+ };
1683
+ return terms.broad.map((query)=>_extends({
1684
+ query: clip(query),
1685
+ orientation
1686
+ }, size, {
1687
+ broad: true,
1688
+ category: terms.core
1689
+ }));
1690
+ };
888
1691
  // 1. Choose, slot by slot, so the page's choices never collide: each
889
1692
  // slot claims its best accepted hit before the next slot looks.
890
1693
  const choices = [];
@@ -894,26 +1697,33 @@ const EXTENSIONS = {
894
1697
  choices.push(null);
895
1698
  continue;
896
1699
  }
897
- const subject = aiStockSubjectWords(slot.alt, (_input_sectionNames_slot_sectionIndex = input.sectionNames[slot.sectionIndex]) != null ? _input_sectionNames_slot_sectionIndex : '');
1700
+ const sectionName = (_input_sectionNames_slot_sectionIndex = input.sectionNames[slot.sectionIndex]) != null ? _input_sectionNames_slot_sectionIndex : '';
1701
+ const subject = aiStockSubjectWords(slot.alt, sectionName);
898
1702
  let choice = null;
899
- const requests = slot.product ? aiStockProductSearches(slot, terms.craft) : aiStockSearchesFor(slot, terms, subject);
900
- for (const request of requests){
901
- var _request_subject;
902
- if (choice || signal.aborted) break;
1703
+ const requests = [
1704
+ ...slot.product ? aiStockProductSearches(slot, terms.craft, terms.domain, {
1705
+ broad: terms.broad,
1706
+ core: terms.core
1707
+ }) : aiStockSearchesFor(slot, terms, subject, aiStockSlotPart(slot, sectionName)),
1708
+ ...craftSearches(slot)
1709
+ ];
1710
+ /** The slot's accepted hits for a search; `null` once the page's searches are spent. */ const accepted = async (request, repeat)=>{
903
1711
  const found = await search(request);
904
- if (found === null) break;
905
- const blocked = new Set(claimed);
906
- for (const hit of found){
907
- const key = stockPhotoSourceKey(hit);
908
- if (elsewhere(key)) blocked.add(key);
1712
+ if (found === null) return null;
1713
+ const blocked = new Set();
1714
+ if (!repeat) {
1715
+ for (const key of claimed)blocked.add(key);
1716
+ for (const hit of found){
1717
+ const key = stockPhotoSourceKey(hit);
1718
+ if (elsewhere(key)) blocked.add(key);
1719
+ }
909
1720
  }
910
- const ranked = aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, _extends({
911
- subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
912
- domain: terms.domain,
913
- strict: slot.role === 'gallery' && !request.broad || !!request.requires
914
- }, request.requires ? {
915
- requires: request.requires
916
- } : {}));
1721
+ return aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, judgementFor(slot, request, subject));
1722
+ };
1723
+ for (const request of requests){
1724
+ if (choice || signal.aborted) break;
1725
+ const ranked = await accepted(request, false);
1726
+ if (ranked === null) break;
917
1727
  if (!ranked.length) continue;
918
1728
  const first = ranked[0];
919
1729
  claimed.add(stockPhotoSourceKey(first));
@@ -922,11 +1732,30 @@ const EXTENSIONS = {
922
1732
  query: request.query
923
1733
  };
924
1734
  }
1735
+ // 2. Nothing new of its own: a product or a hero reuses an on-topic
1736
+ // photo another slot of the job already shows (a product may repeat
1737
+ // a collection tile's photo) before the starters' desk or living
1738
+ // room fills it (AGL-3660). Only the searches already answered are
1739
+ // asked again, from memory. Any other picture keeps "never twice".
1740
+ if (!choice && (slot.product || slot.role === 'hero')) {
1741
+ for (const request of requests){
1742
+ if (choice || signal.aborted) break;
1743
+ const ranked = await accepted(request, true);
1744
+ if (!(ranked == null ? void 0 : ranked.length)) continue;
1745
+ choice = {
1746
+ candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
1747
+ query: request.query,
1748
+ repeat: true
1749
+ };
1750
+ }
1751
+ }
925
1752
  choices.push(choice);
926
1753
  }
927
- // 2. Keep, a few at a time; a copy that fails tries the slot's next
928
- // accepted hit no other slot has claimed.
1754
+ // 3. Keep, a few at a time; a copy that fails tries the slot's next
1755
+ // accepted hit no other slot has claimed. A repeat waits for the
1756
+ // slot it repeats, so the library never holds a photo twice.
929
1757
  const photos = slots.map(()=>null);
1758
+ /** What this pass placed, by source key. */ const placedByKey = new Map();
930
1759
  const fill = async (index)=>{
931
1760
  const choice = choices[index];
932
1761
  const slot = slots[index];
@@ -934,28 +1763,43 @@ const EXTENSIONS = {
934
1763
  for (const [rank, candidate] of choice.candidates.entries()){
935
1764
  if (signal.aborted) return;
936
1765
  const key = stockPhotoSourceKey(candidate);
1766
+ if (choice.repeat) {
1767
+ var _placedByKey_get;
1768
+ const placed = (_placedByKey_get = placedByKey.get(key)) != null ? _placedByKey_get : await keep(candidate, slot, choice.query, true);
1769
+ if (placed) {
1770
+ photos[index] = placed;
1771
+ return;
1772
+ }
1773
+ continue;
1774
+ }
937
1775
  if (rank > 0) {
938
1776
  if (claimed.has(key)) continue;
939
1777
  claimed.add(key);
940
1778
  }
941
1779
  const placed = await keep(candidate, slot, choice.query);
942
1780
  if (placed) {
1781
+ placedByKey.set(key, placed);
943
1782
  photos[index] = placed;
944
1783
  return;
945
1784
  }
946
1785
  }
947
1786
  };
1787
+ const firsts = slots.map((_slot, index)=>index).filter((index)=>{
1788
+ var _choices_index;
1789
+ return !((_choices_index = choices[index]) == null ? void 0 : _choices_index.repeat);
1790
+ });
948
1791
  let next = 0;
949
1792
  const worker = async ()=>{
950
- while(next < slots.length){
951
- const index = next;
1793
+ while(next < firsts.length){
1794
+ const index = firsts[next];
952
1795
  next += 1;
953
1796
  await fill(index);
954
1797
  }
955
1798
  };
956
1799
  await Promise.all(Array.from({
957
- length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, slots.length)
1800
+ length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, firsts.length)
958
1801
  }, worker));
1802
+ for (const [index, choice] of choices.entries())if (choice == null ? void 0 : choice.repeat) await fill(index);
959
1803
  return photos;
960
1804
  };
961
1805
  }