@aglyn/plugins-ai 1.0.0-beta.269 → 1.0.0-beta.271

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 (237) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.d.ts +1 -1
  3. package/src/lib/activity/ai-activity-actions.js +1 -0
  4. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  5. package/src/lib/assets/site-styles/beauty.webp +0 -0
  6. package/src/lib/assets/site-styles/blog.webp +0 -0
  7. package/src/lib/assets/site-styles/business.webp +0 -0
  8. package/src/lib/assets/site-styles/coming-soon.webp +0 -0
  9. package/src/lib/assets/site-styles/education.webp +0 -0
  10. package/src/lib/assets/site-styles/events.webp +0 -0
  11. package/src/lib/assets/site-styles/fitness.webp +0 -0
  12. package/src/lib/assets/site-styles/landing.webp +0 -0
  13. package/src/lib/assets/site-styles/music.webp +0 -0
  14. package/src/lib/assets/site-styles/nonprofit.webp +0 -0
  15. package/src/lib/assets/site-styles/personal.webp +0 -0
  16. package/src/lib/assets/site-styles/photography.webp +0 -0
  17. package/src/lib/assets/site-styles/portfolio.webp +0 -0
  18. package/src/lib/assets/site-styles/professional.webp +0 -0
  19. package/src/lib/assets/site-styles/realestate.webp +0 -0
  20. package/src/lib/assets/site-styles/restaurant.webp +0 -0
  21. package/src/lib/assets/site-styles/store.webp +0 -0
  22. package/src/lib/assets/site-styles/studio.webp +0 -0
  23. package/src/lib/assets/site-styles/trades.webp +0 -0
  24. package/src/lib/assets/site-styles/wellness.webp +0 -0
  25. package/src/lib/assets/site-styles/yoga.webp +0 -0
  26. package/src/lib/components/ai-credits-card.component.d.ts +8 -6
  27. package/src/lib/components/ai-credits-card.component.js +39 -22
  28. package/src/lib/components/ai-credits-card.component.js.map +1 -1
  29. package/src/lib/components/ai-job-input-form.component.d.ts +36 -0
  30. package/src/lib/components/ai-job-input-form.component.js +100 -0
  31. package/src/lib/components/ai-job-input-form.component.js.map +1 -0
  32. package/src/lib/components/ai-job-links.d.ts +6 -0
  33. package/src/lib/components/ai-job-links.js +9 -0
  34. package/src/lib/components/ai-job-links.js.map +1 -1
  35. package/src/lib/components/ai-job-requests.d.ts +4 -0
  36. package/src/lib/components/ai-job-requests.js +4 -0
  37. package/src/lib/components/ai-job-requests.js.map +1 -1
  38. package/src/lib/components/ai-products-hub-card.component.d.ts +6 -0
  39. package/src/lib/components/ai-products-hub-card.component.js +21 -6
  40. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  41. package/src/lib/components/ai-site-build-page.component.js +3 -2
  42. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  43. package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
  44. package/src/lib/components/ai-site-start-card.component.js +99 -31
  45. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  46. package/src/lib/components/ai-site-start-steps.component.d.ts +43 -2
  47. package/src/lib/components/ai-site-start-steps.component.js +651 -15
  48. package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
  49. package/src/lib/components/ai-site-start-tracking.d.ts +10 -0
  50. package/src/lib/components/ai-site-start-tracking.js +14 -0
  51. package/src/lib/components/ai-site-start-tracking.js.map +1 -1
  52. package/src/lib/components/ai-site-style-previews.js +26 -22
  53. package/src/lib/components/ai-site-style-previews.js.map +1 -1
  54. package/src/lib/components/ai-store-finish-card.component.d.ts +14 -3
  55. package/src/lib/components/ai-store-finish-card.component.js +67 -9
  56. package/src/lib/components/ai-store-finish-card.component.js.map +1 -1
  57. package/src/lib/components/ai-upsell-dialog.component.d.ts +7 -5
  58. package/src/lib/components/ai-upsell-dialog.component.js +8 -6
  59. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
  60. package/src/lib/components/assist-build-card.component.js +14 -3
  61. package/src/lib/components/assist-build-card.component.js.map +1 -1
  62. package/src/lib/components/assist-jobs-drawer.component.js +9 -1
  63. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  64. package/src/lib/components/assist-panel.component.js +8 -4
  65. package/src/lib/components/assist-panel.component.js.map +1 -1
  66. package/src/lib/components/staff-org-ai-card.component.js +4 -7
  67. package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
  68. package/src/lib/declarations.d.ts +2 -2
  69. package/src/lib/declarations.js +9 -5
  70. package/src/lib/declarations.js.map +1 -1
  71. package/src/lib/jobs/ai-build-capabilities.js +3 -1
  72. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  73. package/src/lib/jobs/ai-business-profile-prefill.d.ts +1 -1
  74. package/src/lib/jobs/ai-business-profile-prefill.js +14 -3
  75. package/src/lib/jobs/ai-business-profile-prefill.js.map +1 -1
  76. package/src/lib/jobs/ai-job-build-step.d.ts +3 -0
  77. package/src/lib/jobs/ai-job-build-step.js +13 -5
  78. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  79. package/src/lib/jobs/ai-job-form-step.d.ts +24 -1
  80. package/src/lib/jobs/ai-job-form-step.js +93 -7
  81. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  82. package/src/lib/jobs/ai-job-page-language.d.ts +35 -11
  83. package/src/lib/jobs/ai-job-page-language.js +68 -2
  84. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  85. package/src/lib/jobs/ai-job-page-step.d.ts +17 -0
  86. package/src/lib/jobs/ai-job-page-step.js +40 -17
  87. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  88. package/src/lib/jobs/ai-job-plan-step.d.ts +25 -2
  89. package/src/lib/jobs/ai-job-plan-step.js +103 -29
  90. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  91. package/src/lib/jobs/ai-job-settings-step.js +4 -3
  92. package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
  93. package/src/lib/jobs/ai-job-site-datasets.d.ts +27 -0
  94. package/src/lib/jobs/ai-job-site-datasets.js +140 -14
  95. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  96. package/src/lib/jobs/ai-job-site-look.js +18 -5
  97. package/src/lib/jobs/ai-job-site-look.js.map +1 -1
  98. package/src/lib/jobs/ai-job-site-step.d.ts +16 -1
  99. package/src/lib/jobs/ai-job-site-step.js +95 -22
  100. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  101. package/src/lib/jobs/ai-job-text-step.d.ts +8 -1
  102. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  103. package/src/lib/jobs/ai-job-tracking-step.d.ts +14 -3
  104. package/src/lib/jobs/ai-job-tracking-step.js +28 -5
  105. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
  106. package/src/lib/jobs/ai-jobs.d.ts +8 -1
  107. package/src/lib/jobs/ai-jobs.js +63 -8
  108. package/src/lib/jobs/ai-jobs.js.map +1 -1
  109. package/src/lib/jobs/ai-layout-owner-photos.d.ts +57 -0
  110. package/src/lib/jobs/ai-layout-owner-photos.js +110 -0
  111. package/src/lib/jobs/ai-layout-owner-photos.js.map +1 -0
  112. package/src/lib/jobs/ai-layout-stock-photos.d.ts +62 -4
  113. package/src/lib/jobs/ai-layout-stock-photos.js +523 -72
  114. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  115. package/src/lib/jobs/ai-site-inspiration-signals.d.ts +98 -0
  116. package/src/lib/jobs/ai-site-inspiration-signals.js +585 -0
  117. package/src/lib/jobs/ai-site-inspiration-signals.js.map +1 -0
  118. package/src/lib/jobs/ai-site-inspiration.d.ts +157 -0
  119. package/src/lib/jobs/ai-site-inspiration.js +346 -0
  120. package/src/lib/jobs/ai-site-inspiration.js.map +1 -0
  121. package/src/lib/jobs/ai-site-publish.d.ts +26 -1
  122. package/src/lib/jobs/ai-site-publish.js +162 -13
  123. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  124. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +16 -0
  125. package/src/lib/jobs/ai-theme-brand-inputs.js +18 -4
  126. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  127. package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.d.ts +97 -0
  128. package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js +137 -0
  129. package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js.map +1 -0
  130. package/src/lib/jobs/fixtures/ai-site-inspiration-pages.d.ts +38 -0
  131. package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js +103 -0
  132. package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js.map +1 -0
  133. package/src/lib/jobs/fixtures/ai-stock-photo-agl3942-recordings.d.ts +29 -0
  134. package/src/lib/jobs/fixtures/ai-stock-photo-agl3942-recordings.js +291 -0
  135. package/src/lib/jobs/fixtures/ai-stock-photo-agl3942-recordings.js.map +1 -0
  136. package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.d.ts +25 -0
  137. package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js +174 -0
  138. package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js.map +1 -0
  139. package/src/lib/layout-language/ai-layout-compiler.d.ts +1 -1
  140. package/src/lib/layout-language/ai-layout-compiler.js +438 -25
  141. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  142. package/src/lib/layout-language/ai-layout-contact.d.ts +57 -0
  143. package/src/lib/layout-language/ai-layout-contact.js +137 -0
  144. package/src/lib/layout-language/ai-layout-contact.js.map +1 -0
  145. package/src/lib/layout-language/ai-layout-copy.d.ts +12 -0
  146. package/src/lib/layout-language/ai-layout-copy.js +34 -2
  147. package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
  148. package/src/lib/layout-language/ai-layout-design.d.ts +65 -8
  149. package/src/lib/layout-language/ai-layout-design.js +411 -97
  150. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  151. package/src/lib/layout-language/ai-layout-frame.js +23 -0
  152. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  153. package/src/lib/layout-language/ai-layout-labels.d.ts +2 -0
  154. package/src/lib/layout-language/ai-layout-labels.js +12 -6
  155. package/src/lib/layout-language/ai-layout-labels.js.map +1 -1
  156. package/src/lib/layout-language/ai-layout-language.d.ts +3 -2
  157. package/src/lib/layout-language/ai-layout-language.js +4 -3
  158. package/src/lib/layout-language/ai-layout-language.js.map +1 -1
  159. package/src/lib/layout-language/ai-layout-links.d.ts +13 -0
  160. package/src/lib/layout-language/ai-layout-links.js +30 -0
  161. package/src/lib/layout-language/ai-layout-links.js.map +1 -1
  162. package/src/lib/layout-language/ai-layout-pictures.d.ts +49 -6
  163. package/src/lib/layout-language/ai-layout-pictures.js +145 -22
  164. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  165. package/src/lib/model/ai-build-job.d.ts +16 -1
  166. package/src/lib/model/ai-build-job.js +40 -0
  167. package/src/lib/model/ai-build-job.js.map +1 -1
  168. package/src/lib/model/ai-job-input.d.ts +33 -0
  169. package/src/lib/model/ai-job-input.js +59 -0
  170. package/src/lib/model/ai-job-input.js.map +1 -0
  171. package/src/lib/model/ai-job-notice.d.ts +4 -0
  172. package/src/lib/model/ai-job-notice.js +11 -1
  173. package/src/lib/model/ai-job-notice.js.map +1 -1
  174. package/src/lib/model/ai-jobs.types.d.ts +40 -1
  175. package/src/lib/model/ai-jobs.types.js.map +1 -1
  176. package/src/lib/model/ai-site-details.d.ts +122 -0
  177. package/src/lib/model/ai-site-details.js +300 -0
  178. package/src/lib/model/ai-site-details.js.map +1 -0
  179. package/src/lib/model/ai-site-entity.d.ts +181 -0
  180. package/src/lib/model/ai-site-entity.js +390 -0
  181. package/src/lib/model/ai-site-entity.js.map +1 -0
  182. package/src/lib/model/ai-site-inspiration.d.ts +72 -0
  183. package/src/lib/model/ai-site-inspiration.js +109 -0
  184. package/src/lib/model/ai-site-inspiration.js.map +1 -0
  185. package/src/lib/model/ai-site-job.d.ts +13 -1
  186. package/src/lib/model/ai-site-job.js +15 -2
  187. package/src/lib/model/ai-site-job.js.map +1 -1
  188. package/src/lib/model/ai-site-kinds.js +109 -21
  189. package/src/lib/model/ai-site-kinds.js.map +1 -1
  190. package/src/lib/model/ai-site-look.d.ts +10 -0
  191. package/src/lib/model/ai-site-look.js +24 -0
  192. package/src/lib/model/ai-site-look.js.map +1 -1
  193. package/src/lib/model/ai-site-start-seo.d.ts +19 -5
  194. package/src/lib/model/ai-site-start-seo.js +25 -7
  195. package/src/lib/model/ai-site-start-seo.js.map +1 -1
  196. package/src/lib/model/ai-site-start-steps.d.ts +9 -3
  197. package/src/lib/model/ai-site-start-steps.js +67 -5
  198. package/src/lib/model/ai-site-start-steps.js.map +1 -1
  199. package/src/lib/model/ai-site-start.d.ts +68 -4
  200. package/src/lib/model/ai-site-start.js +166 -13
  201. package/src/lib/model/ai-site-start.js.map +1 -1
  202. package/src/lib/model/ai-upsell-copy.js +4 -1
  203. package/src/lib/model/ai-upsell-copy.js.map +1 -1
  204. package/src/lib/native-nav.d.ts +26 -0
  205. package/src/lib/native-nav.js +58 -0
  206. package/src/lib/native-nav.js.map +1 -0
  207. package/src/lib/plan-entitlements.d.ts +4 -3
  208. package/src/lib/plan-entitlements.js +3 -8
  209. package/src/lib/plan-entitlements.js.map +1 -1
  210. package/src/lib/runtime/ai-dataset-generation.d.ts +8 -1
  211. package/src/lib/runtime/ai-dataset-generation.js +12 -2
  212. package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
  213. package/src/lib/runtime/ai-doctrine-validators.d.ts +26 -33
  214. package/src/lib/runtime/ai-doctrine-validators.js +54 -44
  215. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  216. package/src/lib/runtime/ai-doctrine.js +1 -1
  217. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  218. package/src/lib/runtime/ai-node-tree.d.ts +7 -0
  219. package/src/lib/runtime/ai-node-tree.js +9 -1
  220. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  221. package/src/lib/server/ai-create-route.d.ts +1 -1
  222. package/src/lib/server/ai-create-route.js +3 -4
  223. package/src/lib/server/ai-create-route.js.map +1 -1
  224. package/src/lib/server/ai-jobs-resume.js +27 -3
  225. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  226. package/src/lib/server/ai-jobs-route.js +7 -0
  227. package/src/lib/server/ai-jobs-route.js.map +1 -1
  228. package/src/lib/usage/assist-credits.d.ts +4 -4
  229. package/src/lib/usage/assist-credits.js +4 -4
  230. package/src/lib/usage/assist-credits.js.map +1 -1
  231. package/src/lib/usage/staff-org-ai-view.js +6 -3
  232. package/src/lib/usage/staff-org-ai-view.js.map +1 -1
  233. package/src/lib/usage/staff-org-ai.d.ts +4 -2
  234. package/src/lib/usage/staff-org-ai.js +7 -7
  235. package/src/lib/usage/staff-org-ai.js.map +1 -1
  236. package/src/lib/usage/staff-org-usage-ai.js +2 -2
  237. package/src/lib/usage/staff-org-usage-ai.js.map +1 -1
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import type { AiSiteContact } from '../model/ai-site-details';
17
18
  import type { AiLayoutListing } from './ai-layout-listings';
18
19
  /**
19
20
  * Where a link the layout language names goes (AGL-3660), resolved by code to
@@ -70,6 +71,13 @@ export interface AiLayoutTargets {
70
71
  * `ai-layout-listings.ts`). Absent or empty, a page shows only its words.
71
72
  */
72
73
  listings?: readonly AiLayoutListing[];
74
+ /**
75
+ * The business's phone number and email, where the job was given them
76
+ * (AGL-3979): a link to `phone` calls the number and one to `email` writes
77
+ * to the address, and the footer and the page that places the site's form
78
+ * show them. Absent, neither link goes anywhere.
79
+ */
80
+ contact?: AiSiteContact | null;
73
81
  }
74
82
  /** Where a link resolved to. */
75
83
  export type AiLayoutDestination = {
@@ -92,6 +100,11 @@ export type AiLayoutDestination = {
92
100
  href: string;
93
101
  standsInFor?: string;
94
102
  label?: string;
103
+ }
104
+ /** The business's phone (`tel:`) or email (`mailto:`) the job was given (AGL-3979). */
105
+ | {
106
+ kind: 'contact';
107
+ href: string;
95
108
  };
96
109
  /**
97
110
  * A link's words for where it now goes (AGL-3676): a button that named a
@@ -126,6 +126,10 @@ function resolveNamed(to, label, scope, targets) {
126
126
  href: to
127
127
  } : null;
128
128
  }
129
+ // A call or an email goes only to the number and the address the job was
130
+ // given (AGL-3979), however the link spells them.
131
+ const contact = contactDestination(lower, targets.contact);
132
+ if (contact !== undefined) return contact;
129
133
  const section = lower.startsWith('#') ? to.slice(1) : lower.startsWith('section:') ? to.slice(8) : null;
130
134
  if (section !== null) {
131
135
  const number = Number(section.trim());
@@ -138,5 +142,31 @@ function resolveNamed(to, label, scope, targets) {
138
142
  const page = pageNamed(lower.startsWith('page:') ? to.slice(5) : to, targets);
139
143
  return pageDestination(page, label, targets);
140
144
  }
145
+ /**
146
+ * Where a link to the business's phone or email goes (AGL-3979): `phone`,
147
+ * `call` or a `tel:` link with the given number's digits is the number;
148
+ * `email` or a `mailto:` link to the given address is the address. `null` is
149
+ * such a link with nothing given to go to, which is left out; `undefined` is
150
+ * a link that names neither.
151
+ */ function contactDestination(lower, contact) {
152
+ const digits = (value)=>value.replace(/\D/g, '');
153
+ if (lower === 'phone' || lower === 'call' || lower === 'tel' || lower.startsWith('tel:')) {
154
+ if (!(contact == null ? void 0 : contact.phoneHref)) return null;
155
+ if (lower.startsWith('tel:') && digits(lower) !== digits(contact.phoneHref)) return null;
156
+ return {
157
+ kind: 'contact',
158
+ href: contact.phoneHref
159
+ };
160
+ }
161
+ if (lower === 'email' || lower === 'mail' || lower.startsWith('mailto:')) {
162
+ if (!(contact == null ? void 0 : contact.emailHref)) return null;
163
+ if (lower.startsWith('mailto:') && lower.slice(7).split('?')[0] !== contact.email.toLowerCase()) return null;
164
+ return {
165
+ kind: 'contact',
166
+ href: contact.emailHref
167
+ };
168
+ }
169
+ return undefined;
170
+ }
141
171
 
142
172
  //# sourceMappingURL=ai-layout-links.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/layout-language/ai-layout-links.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { aiAsksToGetInTouch } from '../runtime/ai-doctrine-validators'\nimport { aiPageLinkSection } from '../runtime/ai-page-links'\nimport type { AiLayoutListing } from './ai-layout-listings'\n\n/**\n * Where a link the layout language names goes (AGL-3660), resolved by code to\n * a destination rule 10 admits: a page of this site by its id, a section of\n * this page by the platform's Scroll to element interaction, or an `https:`\n * address the brief itself gave. A destination that resolves to nothing, or\n * to the home page under words that do not say home, is no destination, and\n * the compiler leaves the button out rather than shipping a dead control.\n */\n\n/** A page of the site a link may go to: built, or minted on a guided start's plan. */\nexport interface AiLayoutPage {\n id: string\n label: string\n slug: string\n /**\n * A destination on the site that is no page of its own, linked by its path:\n * the blog a guided start writes its posts into (AGL-3660), whose `id` is\n * no screen. Absent for every page.\n */\n href?: string\n /**\n * The title of a planned page merged into this destination (AGL-3676): a\n * page that stood in for the blog, kept only so links to it go to the blog.\n * A link to it is named for the blog, not for the page it replaced.\n */\n standsInFor?: string\n}\n\n/** Everything a page's links resolve against. */\nexport interface AiLayoutTargets {\n /** The page being built, which no link of its own goes to. */\n pageId?: string | null\n /** The site's pages a link may go to. */\n pages: readonly AiLayoutPage[]\n /** The site's home pages, which only words that say home link (rule 10). */\n homeIds: readonly string[]\n /** The saved forms a page may place. */\n forms: readonly { id: string; name: string }[]\n /** The page that places the site's form, where that is another page. */\n formPageId?: string | null\n /** The reusable components a page may place, with the props each fills. */\n components: readonly {\n id: string\n name: string\n props: Record<string, string>\n }[]\n /** Every word the job was given: the brief, the site's answers, its profile. */\n facts: string\n /**\n * The kinds of record the site keeps that its pages show — its catalog,\n * its blog — and the sections that place each (AGL-3676,\n * `ai-layout-listings.ts`). Absent or empty, a page shows only its words.\n */\n listings?: readonly AiLayoutListing[]\n}\n\n/** Where a link resolved to. */\nexport type AiLayoutDestination =\n | { kind: 'page'; screenId: string }\n | { kind: 'section'; index: number }\n | { kind: 'href'; href: string }\n /**\n * A path on this site that is no page of its own, such as the blog\n * (AGL-3660); reached through a planned page merged into it, the title\n * that page had and the name it now goes by (AGL-3676).\n */\n | { kind: 'path'; href: string; standsInFor?: string; label?: string }\n\n/**\n * A link's words for where it now goes (AGL-3676): a button that named a\n * page merged into the blog — \"Read the articles\" — names the blog instead,\n * \"Read the blog\". Words that name neither are kept.\n */\nexport function aiLayoutRenamedLabel(label: string, destination: AiLayoutDestination): string {\n if (destination.kind !== 'path' || !destination.standsInFor || !destination.label) return label\n const name = destination.label.toLowerCase()\n const words = wordsOf(destination.standsInFor)\n if (!words.length) return label\n // The page's own name, or its words alone, singular or plural, whole: a\n // letter in any script continues a word (AGL-3791). `\\b` reads \"ú\" or \"í\"\n // as no letter, so it never matched \"Menú\" and matched \"art\" inside\n // \"artículos\".\n const stems = words.map((word) => word.replace(/s$/, ''))\n const pattern = new RegExp(\n `(?<![\\\\p{L}\\\\p{M}\\\\p{N}])(?:${stems.map((stem) => stem.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('\\\\s+')})s?(?![\\\\p{L}\\\\p{M}\\\\p{N}])`,\n 'giu',\n )\n const renamed = label.replace(pattern, (found) => (found[0] === found[0].toUpperCase() ? destination.label as string : name))\n return renamed\n}\n\n/** The words of a label or a name, as they are compared. */\nfunction wordsOf(text: string): string[] {\n return text\n .toLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean)\n}\n\nfunction slugOf(slug: string): string {\n return `/${slug.replace(/^\\/+|\\/+$/g, '')}`.toLowerCase()\n}\n\nconst HOME_WORDS = /\\bhome(?:\\s?page)?\\b/i\n\n/** Words that ask a visitor to get in touch, which the site's form answers: rule 10's own list (AGL-3729). */\nconst CONTACT_WORDS = { test: aiAsksToGetInTouch }\n\nexport interface AiLayoutLinkScope {\n /** The page's section names in plan order. */\n sections: readonly string[]\n /** The section the link sits in, which it does not scroll to. */\n from: number\n /** The section of this page that places the site's form, if one does. */\n formSection: number | null\n}\n\n/** The page a word names: an id, a label or an address. */\nfunction pageNamed(\n word: string,\n targets: AiLayoutTargets,\n): AiLayoutPage | null {\n const value = word.trim()\n if (!value) return null\n const byId = targets.pages.find((page) => page.id === value)\n if (byId) return byId\n const lower = value.toLowerCase()\n if (lower.startsWith('/'))\n return (\n targets.pages.find((page) => slugOf(page.slug) === slugOf(lower)) ?? null\n )\n const words = wordsOf(value).join(' ')\n return (\n targets.pages.find((page) => wordsOf(page.label).join(' ') === words) ??\n // A page merged into the blog is still named by the title it had (AGL-3676).\n targets.pages.find((page) => page.standsInFor && wordsOf(page.standsInFor).join(' ') === words) ??\n null\n )\n}\n\n/** A destination rule 10 admits, from the page it names, for the words a link shows. */\nfunction pageDestination(\n page: AiLayoutPage | null,\n label: string,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n if (!page || page.id === targets.pageId) return null\n // A link home says so, in English or by the home page's own title: a\n // Spanish site's home is \"Inicio\" (AGL-3760).\n const saysHome = HOME_WORDS.test(label) || (wordsOf(label).join(' ') !== '' && wordsOf(label).join(' ') === wordsOf(page.label).join(' '))\n if (targets.homeIds.includes(page.id) && !saysHome && !isHomeSlug(page.slug)) return null\n if (isHomeSlug(page.slug) && !saysHome) return null\n if (page.href) return { kind: 'path', href: page.href, ...(page.standsInFor ? { standsInFor: page.standsInFor, label: page.label } : {}) }\n return { kind: 'page', screenId: page.id }\n}\n\nfunction isHomeSlug(slug: string): boolean {\n return slugOf(slug) === '/'\n}\n\n/** Where the site's form is: a section of this page, else the page that places it. */\nfunction formDestination(\n scope: AiLayoutLinkScope,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n if (scope.formSection !== null && scope.formSection !== scope.from)\n return { kind: 'section', index: scope.formSection }\n if (targets.formPageId && targets.formPageId !== targets.pageId) {\n return { kind: 'page', screenId: targets.formPageId }\n }\n return null\n}\n\n/**\n * Where a link goes: the destination its `to` names, read the language's\n * way; failing that, for words that ask a visitor to get in touch, the site's\n * form; failing that, a page whose name the words say. `null` is a link with\n * nowhere to go, which is left out.\n */\nexport function aiLayoutResolveLink(\n to: string | undefined,\n label: string,\n scope: AiLayoutLinkScope,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n const named = to ? resolveNamed(to.trim(), label, scope, targets) : null\n if (named) return named\n if (CONTACT_WORDS.test(label)) {\n const form = formDestination(scope, targets)\n if (form) return form\n const contact = targets.pages.find(\n (page) =>\n page.id !== targets.pageId &&\n !isHomeSlug(page.slug) &&\n CONTACT_WORDS.test(page.label),\n )\n if (contact) return { kind: 'page', screenId: contact.id }\n }\n const labelWords = new Set(wordsOf(label))\n const said = targets.pages.filter(\n (page) =>\n page.id !== targets.pageId &&\n !isHomeSlug(page.slug) &&\n wordsOf(page.label).length > 0 &&\n wordsOf(page.label).every((word) => labelWords.has(word)),\n )\n return said.length === 1 ? { kind: 'page', screenId: said[0].id } : null\n}\n\nfunction resolveNamed(\n to: string,\n label: string,\n scope: AiLayoutLinkScope,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n const lower = to.toLowerCase()\n if (\n lower === 'form' ||\n lower.startsWith('form:') ||\n targets.forms.some((form) => form.id === to)\n ) {\n return formDestination(scope, targets)\n }\n if (lower.startsWith('https://')) {\n return targets.facts.includes(to) ? { kind: 'href', href: to } : null\n }\n const section = lower.startsWith('#')\n ? to.slice(1)\n : lower.startsWith('section:')\n ? to.slice(8)\n : null\n if (section !== null) {\n const number = Number(section.trim())\n const index =\n Number.isInteger(number) && number >= 1 && number <= scope.sections.length\n ? number - 1\n : aiPageLinkSection(section, scope.sections)\n return index !== null && index !== scope.from\n ? { kind: 'section', index }\n : null\n }\n const page = pageNamed(lower.startsWith('page:') ? to.slice(5) : to, targets)\n return pageDestination(page, label, targets)\n}\n"],"names":["aiAsksToGetInTouch","aiPageLinkSection","aiLayoutRenamedLabel","label","destination","kind","standsInFor","name","toLowerCase","words","wordsOf","length","stems","map","word","replace","pattern","RegExp","stem","join","renamed","found","toUpperCase","text","split","filter","Boolean","slugOf","slug","HOME_WORDS","CONTACT_WORDS","test","pageNamed","targets","value","trim","byId","pages","find","page","id","lower","startsWith","pageDestination","pageId","saysHome","homeIds","includes","isHomeSlug","href","screenId","formDestination","scope","formSection","from","index","formPageId","aiLayoutResolveLink","to","named","resolveNamed","form","contact","labelWords","Set","said","every","has","forms","some","facts","section","slice","number","Number","isInteger","sections"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/layout-language/ai-layout-links.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { aiAsksToGetInTouch } from '../runtime/ai-doctrine-validators'\nimport { aiPageLinkSection } from '../runtime/ai-page-links'\nimport type { AiSiteContact } from '../model/ai-site-details'\nimport type { AiLayoutListing } from './ai-layout-listings'\n\n/**\n * Where a link the layout language names goes (AGL-3660), resolved by code to\n * a destination rule 10 admits: a page of this site by its id, a section of\n * this page by the platform's Scroll to element interaction, or an `https:`\n * address the brief itself gave. A destination that resolves to nothing, or\n * to the home page under words that do not say home, is no destination, and\n * the compiler leaves the button out rather than shipping a dead control.\n */\n\n/** A page of the site a link may go to: built, or minted on a guided start's plan. */\nexport interface AiLayoutPage {\n id: string\n label: string\n slug: string\n /**\n * A destination on the site that is no page of its own, linked by its path:\n * the blog a guided start writes its posts into (AGL-3660), whose `id` is\n * no screen. Absent for every page.\n */\n href?: string\n /**\n * The title of a planned page merged into this destination (AGL-3676): a\n * page that stood in for the blog, kept only so links to it go to the blog.\n * A link to it is named for the blog, not for the page it replaced.\n */\n standsInFor?: string\n}\n\n/** Everything a page's links resolve against. */\nexport interface AiLayoutTargets {\n /** The page being built, which no link of its own goes to. */\n pageId?: string | null\n /** The site's pages a link may go to. */\n pages: readonly AiLayoutPage[]\n /** The site's home pages, which only words that say home link (rule 10). */\n homeIds: readonly string[]\n /** The saved forms a page may place. */\n forms: readonly { id: string; name: string }[]\n /** The page that places the site's form, where that is another page. */\n formPageId?: string | null\n /** The reusable components a page may place, with the props each fills. */\n components: readonly {\n id: string\n name: string\n props: Record<string, string>\n }[]\n /** Every word the job was given: the brief, the site's answers, its profile. */\n facts: string\n /**\n * The kinds of record the site keeps that its pages show — its catalog,\n * its blog — and the sections that place each (AGL-3676,\n * `ai-layout-listings.ts`). Absent or empty, a page shows only its words.\n */\n listings?: readonly AiLayoutListing[]\n /**\n * The business's phone number and email, where the job was given them\n * (AGL-3979): a link to `phone` calls the number and one to `email` writes\n * to the address, and the footer and the page that places the site's form\n * show them. Absent, neither link goes anywhere.\n */\n contact?: AiSiteContact | null\n}\n\n/** Where a link resolved to. */\nexport type AiLayoutDestination =\n | { kind: 'page'; screenId: string }\n | { kind: 'section'; index: number }\n | { kind: 'href'; href: string }\n /**\n * A path on this site that is no page of its own, such as the blog\n * (AGL-3660); reached through a planned page merged into it, the title\n * that page had and the name it now goes by (AGL-3676).\n */\n | { kind: 'path'; href: string; standsInFor?: string; label?: string }\n /** The business's phone (`tel:`) or email (`mailto:`) the job was given (AGL-3979). */\n | { kind: 'contact'; href: string }\n\n/**\n * A link's words for where it now goes (AGL-3676): a button that named a\n * page merged into the blog — \"Read the articles\" — names the blog instead,\n * \"Read the blog\". Words that name neither are kept.\n */\nexport function aiLayoutRenamedLabel(label: string, destination: AiLayoutDestination): string {\n if (destination.kind !== 'path' || !destination.standsInFor || !destination.label) return label\n const name = destination.label.toLowerCase()\n const words = wordsOf(destination.standsInFor)\n if (!words.length) return label\n // The page's own name, or its words alone, singular or plural, whole: a\n // letter in any script continues a word (AGL-3791). `\\b` reads \"ú\" or \"í\"\n // as no letter, so it never matched \"Menú\" and matched \"art\" inside\n // \"artículos\".\n const stems = words.map((word) => word.replace(/s$/, ''))\n const pattern = new RegExp(\n `(?<![\\\\p{L}\\\\p{M}\\\\p{N}])(?:${stems.map((stem) => stem.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('\\\\s+')})s?(?![\\\\p{L}\\\\p{M}\\\\p{N}])`,\n 'giu',\n )\n const renamed = label.replace(pattern, (found) => (found[0] === found[0].toUpperCase() ? destination.label as string : name))\n return renamed\n}\n\n/** The words of a label or a name, as they are compared. */\nfunction wordsOf(text: string): string[] {\n return text\n .toLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean)\n}\n\nfunction slugOf(slug: string): string {\n return `/${slug.replace(/^\\/+|\\/+$/g, '')}`.toLowerCase()\n}\n\nconst HOME_WORDS = /\\bhome(?:\\s?page)?\\b/i\n\n/** Words that ask a visitor to get in touch, which the site's form answers: rule 10's own list (AGL-3729). */\nconst CONTACT_WORDS = { test: aiAsksToGetInTouch }\n\nexport interface AiLayoutLinkScope {\n /** The page's section names in plan order. */\n sections: readonly string[]\n /** The section the link sits in, which it does not scroll to. */\n from: number\n /** The section of this page that places the site's form, if one does. */\n formSection: number | null\n}\n\n/** The page a word names: an id, a label or an address. */\nfunction pageNamed(\n word: string,\n targets: AiLayoutTargets,\n): AiLayoutPage | null {\n const value = word.trim()\n if (!value) return null\n const byId = targets.pages.find((page) => page.id === value)\n if (byId) return byId\n const lower = value.toLowerCase()\n if (lower.startsWith('/'))\n return (\n targets.pages.find((page) => slugOf(page.slug) === slugOf(lower)) ?? null\n )\n const words = wordsOf(value).join(' ')\n return (\n targets.pages.find((page) => wordsOf(page.label).join(' ') === words) ??\n // A page merged into the blog is still named by the title it had (AGL-3676).\n targets.pages.find((page) => page.standsInFor && wordsOf(page.standsInFor).join(' ') === words) ??\n null\n )\n}\n\n/** A destination rule 10 admits, from the page it names, for the words a link shows. */\nfunction pageDestination(\n page: AiLayoutPage | null,\n label: string,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n if (!page || page.id === targets.pageId) return null\n // A link home says so, in English or by the home page's own title: a\n // Spanish site's home is \"Inicio\" (AGL-3760).\n const saysHome = HOME_WORDS.test(label) || (wordsOf(label).join(' ') !== '' && wordsOf(label).join(' ') === wordsOf(page.label).join(' '))\n if (targets.homeIds.includes(page.id) && !saysHome && !isHomeSlug(page.slug)) return null\n if (isHomeSlug(page.slug) && !saysHome) return null\n if (page.href) return { kind: 'path', href: page.href, ...(page.standsInFor ? { standsInFor: page.standsInFor, label: page.label } : {}) }\n return { kind: 'page', screenId: page.id }\n}\n\nfunction isHomeSlug(slug: string): boolean {\n return slugOf(slug) === '/'\n}\n\n/** Where the site's form is: a section of this page, else the page that places it. */\nfunction formDestination(\n scope: AiLayoutLinkScope,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n if (scope.formSection !== null && scope.formSection !== scope.from)\n return { kind: 'section', index: scope.formSection }\n if (targets.formPageId && targets.formPageId !== targets.pageId) {\n return { kind: 'page', screenId: targets.formPageId }\n }\n return null\n}\n\n/**\n * Where a link goes: the destination its `to` names, read the language's\n * way; failing that, for words that ask a visitor to get in touch, the site's\n * form; failing that, a page whose name the words say. `null` is a link with\n * nowhere to go, which is left out.\n */\nexport function aiLayoutResolveLink(\n to: string | undefined,\n label: string,\n scope: AiLayoutLinkScope,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n const named = to ? resolveNamed(to.trim(), label, scope, targets) : null\n if (named) return named\n if (CONTACT_WORDS.test(label)) {\n const form = formDestination(scope, targets)\n if (form) return form\n const contact = targets.pages.find(\n (page) =>\n page.id !== targets.pageId &&\n !isHomeSlug(page.slug) &&\n CONTACT_WORDS.test(page.label),\n )\n if (contact) return { kind: 'page', screenId: contact.id }\n }\n const labelWords = new Set(wordsOf(label))\n const said = targets.pages.filter(\n (page) =>\n page.id !== targets.pageId &&\n !isHomeSlug(page.slug) &&\n wordsOf(page.label).length > 0 &&\n wordsOf(page.label).every((word) => labelWords.has(word)),\n )\n return said.length === 1 ? { kind: 'page', screenId: said[0].id } : null\n}\n\nfunction resolveNamed(\n to: string,\n label: string,\n scope: AiLayoutLinkScope,\n targets: AiLayoutTargets,\n): AiLayoutDestination | null {\n const lower = to.toLowerCase()\n if (\n lower === 'form' ||\n lower.startsWith('form:') ||\n targets.forms.some((form) => form.id === to)\n ) {\n return formDestination(scope, targets)\n }\n if (lower.startsWith('https://')) {\n return targets.facts.includes(to) ? { kind: 'href', href: to } : null\n }\n // A call or an email goes only to the number and the address the job was\n // given (AGL-3979), however the link spells them.\n const contact = contactDestination(lower, targets.contact)\n if (contact !== undefined) return contact\n const section = lower.startsWith('#')\n ? to.slice(1)\n : lower.startsWith('section:')\n ? to.slice(8)\n : null\n if (section !== null) {\n const number = Number(section.trim())\n const index =\n Number.isInteger(number) && number >= 1 && number <= scope.sections.length\n ? number - 1\n : aiPageLinkSection(section, scope.sections)\n return index !== null && index !== scope.from\n ? { kind: 'section', index }\n : null\n }\n const page = pageNamed(lower.startsWith('page:') ? to.slice(5) : to, targets)\n return pageDestination(page, label, targets)\n}\n\n/**\n * Where a link to the business's phone or email goes (AGL-3979): `phone`,\n * `call` or a `tel:` link with the given number's digits is the number;\n * `email` or a `mailto:` link to the given address is the address. `null` is\n * such a link with nothing given to go to, which is left out; `undefined` is\n * a link that names neither.\n */\nfunction contactDestination(\n lower: string,\n contact: AiSiteContact | null | undefined,\n): AiLayoutDestination | null | undefined {\n const digits = (value: string) => value.replace(/\\D/g, '')\n if (lower === 'phone' || lower === 'call' || lower === 'tel' || lower.startsWith('tel:')) {\n if (!contact?.phoneHref) return null\n if (lower.startsWith('tel:') && digits(lower) !== digits(contact.phoneHref)) return null\n return { kind: 'contact', href: contact.phoneHref }\n }\n if (lower === 'email' || lower === 'mail' || lower.startsWith('mailto:')) {\n if (!contact?.emailHref) return null\n if (lower.startsWith('mailto:') && lower.slice(7).split('?')[0] !== contact.email.toLowerCase()) return null\n return { kind: 'contact', href: contact.emailHref }\n }\n return undefined\n}\n"],"names":["aiAsksToGetInTouch","aiPageLinkSection","aiLayoutRenamedLabel","label","destination","kind","standsInFor","name","toLowerCase","words","wordsOf","length","stems","map","word","replace","pattern","RegExp","stem","join","renamed","found","toUpperCase","text","split","filter","Boolean","slugOf","slug","HOME_WORDS","CONTACT_WORDS","test","pageNamed","targets","value","trim","byId","pages","find","page","id","lower","startsWith","pageDestination","pageId","saysHome","homeIds","includes","isHomeSlug","href","screenId","formDestination","scope","formSection","from","index","formPageId","aiLayoutResolveLink","to","named","resolveNamed","form","contact","labelWords","Set","said","every","has","forms","some","facts","contactDestination","undefined","section","slice","number","Number","isInteger","sections","digits","phoneHref","emailHref","email"],"mappings":"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"}
@@ -24,13 +24,19 @@ import type { NodesMap } from '@aglyn/aglyn/types/nodes';
24
24
  * stored page — it finds each empty slot, decides what kind of picture the
25
25
  * slot is, and fills it with a photo the site can serve from its own origin.
26
26
  *
27
- * Two sources fill a slot, in order:
27
+ * Three sources fill a slot, in order:
28
28
  *
29
29
  * 1. A stock photo searched for by the slot's own description and copied into
30
30
  * the site's media library (`jobs/ai-layout-stock-photos.ts`), when the
31
31
  * deployment has a stock-photo key.
32
- * 2. Where the library cannot be asked at all — no stock-photo key, the
33
- * library down, refusing or out of time before it answered — one of the
32
+ * 2. Where the library failed the slot — down, refusing, out of time, or its
33
+ * photo not copied — one of the photos the same job already placed on
34
+ * this site, on its other pages or its datasets' records
35
+ * ({@link aiLayoutJobPhotoPicks}, AGL-3988): each was found for the
36
+ * site's own world, so a wedding photographer's home page shows a wedding
37
+ * and never an office.
38
+ * 3. Where the job has no photo of its own — no stock-photo key, or the
39
+ * library failed it from the start — one of the
34
40
  * STARTER PHOTOS every new site is born with (`DEFAULT_SITE_IMAGES`,
35
41
  * served at `/_static/starter/` by the tenant and the console alike,
36
42
  * credited in the CREDITS file beside them). They are the platform's own
@@ -190,6 +196,14 @@ export declare function aiLayoutSeedNumber(seed: string): number;
190
196
  * people; a gallery picture never takes it, even to avoid a repeat.
191
197
  */
192
198
  export declare function aiLayoutStarterPhotos(slots: readonly AiLayoutPictureSlot[], seed: string): AiLayoutPicturePhoto[];
199
+ /**
200
+ * A neutral starter for each of a list's records (AGL-3988), where the site
201
+ * has a library and the job has no photo of its own: the two taken in turn
202
+ * from the seed, so no record shows the photo of the one beside it. A card
203
+ * of a listing cannot drop its frame as a page's picture does, so every
204
+ * record takes one. Pure.
205
+ */
206
+ export declare function aiLayoutNeutralStarterRun(count: number, seed: string): AiLayoutPicturePhoto[];
193
207
  /**
194
208
  * A neutral starter for each slot a library could not fill, where the site
195
209
  * has a library (AGL-3923): each at most once on a page, the hero served
@@ -198,6 +212,25 @@ export declare function aiLayoutStarterPhotos(slots: readonly AiLayoutPictureSlo
198
212
  * none keeps the no-photo treatment.
199
213
  */
200
214
  export declare function aiLayoutNeutralStarterPhotos(slots: readonly AiLayoutPictureSlot[], seed: string): Array<AiLayoutPicturePhoto | null>;
215
+ /**
216
+ * The job's own photos for the slots a library could not fill (AGL-3988):
217
+ * the photos THIS job already placed on this site — on its other pages, on
218
+ * its datasets' records, and on this page — which were each found for the
219
+ * site's own world, before any starter. The beta.270 Sugar Hollow
220
+ * Photography start (2026-10-19) opened a wedding photographer's home page
221
+ * on the starters' desk and an office team, and filed five wedding galleries
222
+ * under a workbench, a windowsill and the same office, while the library
223
+ * had already given the job three wedding and family photos.
224
+ *
225
+ * Each slot `need` names takes the one that fits it best: one this page does
226
+ * not show yet before one it does, a wide photo for a hero, the shape
227
+ * nearest its frame, and the most words shared with what the slot says it
228
+ * shows; never the photo of the slot beside it. A product's photo and a
229
+ * portrait of the business's people are of one named thing, which no other
230
+ * photo is, so they take none. Among equals the seed picks, so one job
231
+ * always picks the same. `null` for a slot given none. Pure.
232
+ */
233
+ export declare function aiLayoutJobPhotoPicks(slots: readonly AiLayoutPictureSlot[], need: readonly boolean[], jobPhotos: readonly AiLayoutPicturePhoto[], placed: ReadonlyArray<AiLayoutPicturePhoto | null | undefined>, seed: string): Array<AiLayoutPicturePhoto | null>;
201
234
  /**
202
235
  * The page with each slot filled by its photo: the `image` given its source,
203
236
  * its intrinsic size and how it loads (the hero eagerly, as the starter home
@@ -232,9 +265,13 @@ export type AiLayoutPictureSource = (slots: readonly AiLayoutPictureSlot[]) => P
232
265
  /**
233
266
  * Fills a stored page's empty picture slots (AGL-3660): from `source` where
234
267
  * one is given and it answers; a slot it answers with nothing fitting stays
235
- * without a photo and is taken out ({@link aiLayoutDropEmptyPictures}); every
236
- * other slot takes a starter photo, described by its own words (AGL-3831). Never throws, and never leaves a page worse than it
237
- * came: a picture is decoration, and a job never fails over one.
268
+ * without a photo and is taken out ({@link aiLayoutDropEmptyPictures}). A
269
+ * slot it could not answer — the library down, out of time, or its photo not
270
+ * copied — takes one of the photos the job already placed on this site
271
+ * (`jobPhotos`, {@link aiLayoutJobPhotoPicks}, AGL-3988), and only where the
272
+ * job has none of its own, a starter photo described by its own words
273
+ * (AGL-3831). Never throws, and never leaves a page worse than it came: a
274
+ * picture is decoration, and a job never fails over one.
238
275
  */
239
276
  export declare function aiResolveLayoutPictures(nodes: NodesMap, input: {
240
277
  rootId: string;
@@ -243,6 +280,12 @@ export declare function aiResolveLayoutPictures(nodes: NodesMap, input: {
243
280
  /** A per-job word, so two sites do not open with the same photo. */
244
281
  seed: string;
245
282
  source?: AiLayoutPictureSource | null;
283
+ /**
284
+ * The photos this job already placed on this site (AGL-3988): its other
285
+ * pages' and its datasets' records', each found for the site's world.
286
+ * Taken before any starter where `source` could not fill a slot.
287
+ */
288
+ jobPhotos?: readonly AiLayoutPicturePhoto[];
246
289
  /** The site's language (AGL-3750); a starter photo's English words are not its own. */
247
290
  language?: string;
248
291
  }): Promise<NodesMap>;
@@ -244,6 +244,26 @@ function rotate(list, by) {
244
244
  'hero',
245
245
  'desk'
246
246
  ];
247
+ /**
248
+ * A neutral starter for each of a list's records (AGL-3988), where the site
249
+ * has a library and the job has no photo of its own: the two taken in turn
250
+ * from the seed, so no record shows the photo of the one beside it. A card
251
+ * of a listing cannot drop its frame as a page's picture does, so every
252
+ * record takes one. Pure.
253
+ */ export function aiLayoutNeutralStarterRun(count, seed) {
254
+ const pool = rotate(NEUTRAL_STARTERS, aiLayoutSeedNumber(seed));
255
+ return Array.from({
256
+ length: Math.max(0, count)
257
+ }, (_, index)=>{
258
+ const photo = AI_LAYOUT_STARTER_PHOTOS[pool[index % pool.length]];
259
+ return {
260
+ src: photo.src,
261
+ width: photo.width,
262
+ height: photo.height,
263
+ alt: photo.alt
264
+ };
265
+ });
266
+ }
247
267
  /**
248
268
  * A neutral starter for each slot a library could not fill, where the site
249
269
  * has a library (AGL-3923): each at most once on a page, the hero served
@@ -275,6 +295,77 @@ function rotate(list, by) {
275
295
  });
276
296
  return photos;
277
297
  }
298
+ /** A text's words a photo's own words may share: lowercase, four letters or more. */ const wordsOf = (text)=>new Set(text.toLowerCase().split(/[^a-zÀ-ɏ]+/).filter((word)=>word.length >= 4));
299
+ /**
300
+ * The job's own photos for the slots a library could not fill (AGL-3988):
301
+ * the photos THIS job already placed on this site — on its other pages, on
302
+ * its datasets' records, and on this page — which were each found for the
303
+ * site's own world, before any starter. The beta.270 Sugar Hollow
304
+ * Photography start (2026-10-19) opened a wedding photographer's home page
305
+ * on the starters' desk and an office team, and filed five wedding galleries
306
+ * under a workbench, a windowsill and the same office, while the library
307
+ * had already given the job three wedding and family photos.
308
+ *
309
+ * Each slot `need` names takes the one that fits it best: one this page does
310
+ * not show yet before one it does, a wide photo for a hero, the shape
311
+ * nearest its frame, and the most words shared with what the slot says it
312
+ * shows; never the photo of the slot beside it. A product's photo and a
313
+ * portrait of the business's people are of one named thing, which no other
314
+ * photo is, so they take none. Among equals the seed picks, so one job
315
+ * always picks the same. `null` for a slot given none. Pure.
316
+ */ export function aiLayoutJobPhotoPicks(slots, need, jobPhotos, placed, seed) {
317
+ const seen = new Set();
318
+ const pool = jobPhotos.filter((photo)=>{
319
+ if (!photo.src || !(photo.width > 0) || !(photo.height > 0) || seen.has(photo.src)) return false;
320
+ seen.add(photo.src);
321
+ return true;
322
+ });
323
+ const picks = slots.map(()=>null);
324
+ if (!pool.length) return picks;
325
+ const shown = slots.map((_slot, index)=>{
326
+ var _placed_index;
327
+ return ((_placed_index = placed[index]) == null ? void 0 : _placed_index.src) || '';
328
+ });
329
+ const order = [
330
+ 'hero',
331
+ 'about',
332
+ 'gallery'
333
+ ];
334
+ const served = slots.map((slot, index)=>({
335
+ slot,
336
+ index
337
+ })).filter(({ slot, index })=>need[index] && !slot.product && !slot.people).sort((a, b)=>order.indexOf(a.slot.role) - order.indexOf(b.slot.role) || a.index - b.index);
338
+ const turn = aiLayoutSeedNumber(seed);
339
+ for (const { slot, index } of served){
340
+ const beside = new Set([
341
+ shown[index - 1],
342
+ shown[index + 1]
343
+ ].filter(Boolean));
344
+ const says = wordsOf(slot.alt);
345
+ let best = null;
346
+ for (const [at, photo] of pool.entries()){
347
+ var _photo_alt;
348
+ if (beside.has(photo.src)) continue;
349
+ const aspect = photo.width / photo.height;
350
+ const uses = shown.filter((src)=>src === photo.src).length;
351
+ const shared = [
352
+ ...wordsOf((_photo_alt = photo.alt) != null ? _photo_alt : '')
353
+ ].filter((word)=>says.has(word)).length;
354
+ const score = -100 * uses + (slot.role === 'hero' && aspect >= 1.3 ? 20 : 0) - 10 * Math.abs(Math.log(aspect / (slot.aspect > 0 ? slot.aspect : 1))) + 4 * shared;
355
+ // The seed turns the pool, so equals are taken in a per-job order.
356
+ const rank = (at - turn % pool.length + pool.length) % pool.length;
357
+ if (!best || score > best.score || score === best.score && rank < best.rank) best = {
358
+ photo,
359
+ score,
360
+ rank
361
+ };
362
+ }
363
+ if (!best) continue;
364
+ picks[index] = _extends({}, best.photo);
365
+ shown[index] = best.photo.src;
366
+ }
367
+ return picks;
368
+ }
278
369
  /**
279
370
  * The page with each slot filled by its photo: the `image` given its source,
280
371
  * its intrinsic size and how it loads (the hero eagerly, as the starter home
@@ -290,7 +381,7 @@ function rotate(list, by) {
290
381
  const lead = slots.findIndex((_, index)=>Boolean(photos[index] && next[slots[index].imageId]));
291
382
  slots.forEach((slot, index)=>{
292
383
  var _node_props;
293
- var _photo_alt, _photo_alt1;
384
+ var _photo_alt;
294
385
  const photo = photos[index];
295
386
  const node = next[slot.imageId];
296
387
  if (!photo || !node) return;
@@ -298,14 +389,18 @@ function rotate(list, by) {
298
389
  delete props['loading'];
299
390
  // A photo placed is described by its words, never hidden from a screen
300
391
  // reader: a stand-in was decorative only while it showed nothing (AGL-3856).
301
- if ((_photo_alt = photo.alt) == null ? void 0 : _photo_alt.trim()) delete props['decorative'];
392
+ // One that brings no words of its own, such as an owner's upload with no
393
+ // alt text, keeps the slot's (AGL-3979): a picture a visitor sees always
394
+ // has a description.
395
+ const alt = ((_photo_alt = photo.alt) == null ? void 0 : _photo_alt.trim()) || (typeof props['alt'] === 'string' ? props['alt'].trim() : '');
396
+ if (alt) delete props['decorative'];
302
397
  next[slot.imageId] = _extends({}, node, {
303
398
  props: _extends({}, props, {
304
399
  src: photo.src,
305
400
  intrinsicWidth: photo.width,
306
401
  intrinsicHeight: photo.height
307
- }, ((_photo_alt1 = photo.alt) == null ? void 0 : _photo_alt1.trim()) ? {
308
- alt: photo.alt.trim()
402
+ }, alt ? {
403
+ alt
309
404
  } : {}, index === lead && slot.role === 'hero' ? {
310
405
  loading: 'eager'
311
406
  } : {})
@@ -404,11 +499,16 @@ function rotate(list, by) {
404
499
  /**
405
500
  * Fills a stored page's empty picture slots (AGL-3660): from `source` where
406
501
  * one is given and it answers; a slot it answers with nothing fitting stays
407
- * without a photo and is taken out ({@link aiLayoutDropEmptyPictures}); every
408
- * other slot takes a starter photo, described by its own words (AGL-3831). Never throws, and never leaves a page worse than it
409
- * came: a picture is decoration, and a job never fails over one.
502
+ * without a photo and is taken out ({@link aiLayoutDropEmptyPictures}). A
503
+ * slot it could not answer — the library down, out of time, or its photo not
504
+ * copied — takes one of the photos the job already placed on this site
505
+ * (`jobPhotos`, {@link aiLayoutJobPhotoPicks}, AGL-3988), and only where the
506
+ * job has none of its own, a starter photo described by its own words
507
+ * (AGL-3831). Never throws, and never leaves a page worse than it came: a
508
+ * picture is decoration, and a job never fails over one.
410
509
  */ export async function aiResolveLayoutPictures(nodes, input) {
411
510
  try {
511
+ var _input_jobPhotos;
412
512
  const slots = aiLayoutPictureSlots(nodes, input.rootId, {
413
513
  ids: input.sectionIds,
414
514
  names: input.sectionNames
@@ -419,7 +519,7 @@ function rotate(list, by) {
419
519
  try {
420
520
  found = await input.source(slots);
421
521
  } catch (error) {
422
- console.warn('ai layout pictures: the stock source failed; starter photos fill the page', {
522
+ console.warn('ai layout pictures: the stock source failed; the job’s own photos or starters fill the page', {
423
523
  error
424
524
  });
425
525
  found = [];
@@ -431,28 +531,51 @@ function rotate(list, by) {
431
531
  const said = (photo)=>otherLanguage ? _extends({}, photo, {
432
532
  alt: aiLayoutLabel('illustrativePhoto', input.language)
433
533
  }) : photo;
434
- // With a library, a starter is only ever a neutral one (AGL-3923): the
435
- // beta.267 Brightvale start, whose library refused its copies, showed a
436
- // pottery bench and a coffee shop's owner as a marketer's pictures.
437
- const starters = (input.source ? aiLayoutNeutralStarterPhotos(slots, input.seed) : aiLayoutStarterPhotos(slots, input.seed)).map((photo)=>photo ? said(photo) : null);
534
+ /** The slots the source could not answer: no library, or it failed them. */ const unanswered = slots.map((_slot, index)=>!found[index]);
438
535
  const photos = slots.map((_slot, index)=>{
439
- var _starters_index;
440
536
  const answer = found[index];
441
- if (answer) return answer.src ? answer : null;
442
- return (_starters_index = starters[index]) != null ? _starters_index : null;
537
+ return (answer == null ? void 0 : answer.src) ? answer : null;
443
538
  });
539
+ // With a library, the job's own photos come before any starter (AGL-3988),
540
+ // and a starter is only ever a neutral one (AGL-3923): the beta.267
541
+ // Brightvale start, whose library refused its copies, showed a pottery
542
+ // bench and a coffee shop's owner as a marketer's pictures.
543
+ const own = input.source ? ((_input_jobPhotos = input.jobPhotos) != null ? _input_jobPhotos : []).filter((photo)=>photo.src && photo.width > 0 && photo.height > 0) : [];
544
+ if (own.length) {
545
+ aiLayoutJobPhotoPicks(slots, unanswered, own, photos, input.seed).forEach((pick, index)=>{
546
+ if (pick && unanswered[index]) photos[index] = pick;
547
+ });
548
+ } else {
549
+ const starters = input.source ? aiLayoutNeutralStarterPhotos(slots, input.seed) : aiLayoutStarterPhotos(slots, input.seed);
550
+ starters.forEach((photo, index)=>{
551
+ if (photo && unanswered[index]) photos[index] = said(photo);
552
+ });
553
+ }
444
554
  // The page floor (AGL-3923; Zach, 2026-10-12: a starter photo before no
445
555
  // photo at all): a page the library answered that would show no picture
446
- // keeps one neutral starter, in its hero, else its first slot.
556
+ // keeps one, in its hero, else its first slot: one of the job's own
557
+ // photos where it has any (AGL-3988), else a neutral starter.
447
558
  if (input.source && !photos.some((photo)=>photo == null ? void 0 : photo.src)) {
448
559
  const at = Math.max(0, slots.findIndex((slot)=>slot.role === 'hero'));
560
+ const lead = _extends({}, slots[at], {
561
+ product: undefined,
562
+ people: undefined
563
+ });
564
+ const [pick] = aiLayoutJobPhotoPicks([
565
+ lead
566
+ ], [
567
+ true
568
+ ], own, [
569
+ null
570
+ ], input.seed);
449
571
  const [floor] = rotate(NEUTRAL_STARTERS, aiLayoutSeedNumber(input.seed));
450
- const photo = floor ? AI_LAYOUT_STARTER_PHOTOS[floor] : null;
451
- if (photo) photos[at] = said({
452
- src: photo.src,
453
- width: photo.width,
454
- height: photo.height,
455
- alt: photo.alt
572
+ const starter = floor ? AI_LAYOUT_STARTER_PHOTOS[floor] : null;
573
+ if (pick) photos[at] = pick;
574
+ else if (starter) photos[at] = said({
575
+ src: starter.src,
576
+ width: starter.width,
577
+ height: starter.height,
578
+ alt: starter.alt
456
579
  });
457
580
  }
458
581
  return aiLayoutDropEmptyPictures(aiLayoutPlacePictures(nodes, slots, photos), slots, photos, input.sectionIds);