@aglyn/plugins-ai 1.0.0-beta.233 → 1.0.0-beta.235

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 (199) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.d.ts +25 -0
  3. package/src/lib/activity/ai-activity-actions.js +85 -4
  4. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  5. package/src/lib/components/ai-brief-dialog.component.js +46 -2
  6. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  7. package/src/lib/components/ai-campaign-create.component.js +1 -1
  8. package/src/lib/components/ai-campaign-create.component.js.map +1 -1
  9. package/src/lib/components/ai-credits-card.component.js +3 -3
  10. package/src/lib/components/ai-credits-card.component.js.map +1 -1
  11. package/src/lib/components/ai-crm-email-draft.component.js +1 -1
  12. package/src/lib/components/ai-crm-email-draft.component.js.map +1 -1
  13. package/src/lib/components/ai-crm-import-mapping.component.js +1 -1
  14. package/src/lib/components/ai-crm-import-mapping.component.js.map +1 -1
  15. package/src/lib/components/ai-crm-record-card.component.js +1 -1
  16. package/src/lib/components/ai-crm-record-card.component.js.map +1 -1
  17. package/src/lib/components/ai-describe-automation.component.js +4 -2
  18. package/src/lib/components/ai-describe-automation.component.js.map +1 -1
  19. package/src/lib/components/ai-describe-org-automation.component.js +1 -1
  20. package/src/lib/components/ai-describe-org-automation.component.js.map +1 -1
  21. package/src/lib/components/ai-experiment-cards.component.js +2 -2
  22. package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
  23. package/src/lib/components/ai-explain-automation.component.js +1 -1
  24. package/src/lib/components/ai-explain-automation.component.js.map +1 -1
  25. package/src/lib/components/ai-insight-card.component.js +3 -1
  26. package/src/lib/components/ai-insight-card.component.js.map +1 -1
  27. package/src/lib/components/ai-job-links.d.ts +6 -0
  28. package/src/lib/components/ai-job-links.js +9 -0
  29. package/src/lib/components/ai-job-links.js.map +1 -1
  30. package/src/lib/components/ai-jobs-list-page.component.d.ts +6 -1
  31. package/src/lib/components/ai-jobs-list-page.component.js +15 -2
  32. package/src/lib/components/ai-jobs-list-page.component.js.map +1 -1
  33. package/src/lib/components/ai-logic.component.js +5 -3
  34. package/src/lib/components/ai-logic.component.js.map +1 -1
  35. package/src/lib/components/ai-media-create.component.js +88 -16
  36. package/src/lib/components/ai-media-create.component.js.map +1 -1
  37. package/src/lib/components/ai-model-selector.component.js +8 -0
  38. package/src/lib/components/ai-model-selector.component.js.map +1 -1
  39. package/src/lib/components/ai-overlay-cards.component.js +2 -2
  40. package/src/lib/components/ai-overlay-cards.component.js.map +1 -1
  41. package/src/lib/components/ai-product-copy-card.component.js +2 -1
  42. package/src/lib/components/ai-product-copy-card.component.js.map +1 -1
  43. package/src/lib/components/ai-products-hub-card.component.js +2 -1
  44. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  45. package/src/lib/components/ai-revise-automation.component.js +1 -1
  46. package/src/lib/components/ai-revise-automation.component.js.map +1 -1
  47. package/src/lib/components/ai-seo-fields-card.component.js +7 -3
  48. package/src/lib/components/ai-seo-fields-card.component.js.map +1 -1
  49. package/src/lib/components/ai-site-batch-card.component.js +7 -4
  50. package/src/lib/components/ai-site-batch-card.component.js.map +1 -1
  51. package/src/lib/components/ai-site-build-page.component.js +48 -6
  52. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  53. package/src/lib/components/ai-site-seo-start-card.component.js +7 -4
  54. package/src/lib/components/ai-site-seo-start-card.component.js.map +1 -1
  55. package/src/lib/components/ai-site-start-card.component.js +152 -36
  56. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  57. package/src/lib/components/ai-theme-proposal-card.component.js +1 -0
  58. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  59. package/src/lib/components/ai-upsell-dialog.component.js +17 -3
  60. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
  61. package/src/lib/components/ai-usage-strip.component.js +13 -2
  62. package/src/lib/components/ai-usage-strip.component.js.map +1 -1
  63. package/src/lib/components/assist-build-card.component.js +20 -3
  64. package/src/lib/components/assist-build-card.component.js.map +1 -1
  65. package/src/lib/components/assist-jobs-drawer.component.js +7 -1
  66. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  67. package/src/lib/components/billing-ai-allotments.component.js +2 -2
  68. package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
  69. package/src/lib/components/billing-ai-top-users.component.js +1 -0
  70. package/src/lib/components/billing-ai-top-users.component.js.map +1 -1
  71. package/src/lib/components/billing-assist-overage-card.component.js +2 -2
  72. package/src/lib/components/billing-assist-overage-card.component.js.map +1 -1
  73. package/src/lib/components/host-ai-allotments.component.js +2 -2
  74. package/src/lib/components/host-ai-allotments.component.js.map +1 -1
  75. package/src/lib/components/member-ai-allotment-card.component.js +2 -2
  76. package/src/lib/components/member-ai-allotment-card.component.js.map +1 -1
  77. package/src/lib/components/staff-org-ai-card.component.js +1 -0
  78. package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
  79. package/src/lib/components/staff-org-ai-conversations.component.d.ts +9 -3
  80. package/src/lib/components/staff-org-ai-conversations.component.js +174 -83
  81. package/src/lib/components/staff-org-ai-conversations.component.js.map +1 -1
  82. package/src/lib/components/staff-user-ai-usage-card.component.js +2 -1
  83. package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
  84. package/src/lib/jobs/ai-job-admission.d.ts +2 -1
  85. package/src/lib/jobs/ai-job-admission.js.map +1 -1
  86. package/src/lib/jobs/ai-job-email-step.d.ts +39 -0
  87. package/src/lib/jobs/ai-job-email-step.js +116 -5
  88. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  89. package/src/lib/jobs/ai-job-layout-language.d.ts +3 -0
  90. package/src/lib/jobs/ai-job-layout-language.js +6 -1
  91. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  92. package/src/lib/jobs/ai-job-layout-site-pages.d.ts +14 -0
  93. package/src/lib/jobs/ai-job-layout-site-pages.js +43 -11
  94. package/src/lib/jobs/ai-job-layout-site-pages.js.map +1 -1
  95. package/src/lib/jobs/ai-job-layout-step.js +3 -2
  96. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  97. package/src/lib/jobs/ai-job-page-language.d.ts +26 -1
  98. package/src/lib/jobs/ai-job-page-language.js +75 -13
  99. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  100. package/src/lib/jobs/ai-job-page-step.d.ts +3 -0
  101. package/src/lib/jobs/ai-job-page-step.js +32 -7
  102. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  103. package/src/lib/jobs/ai-job-plan-step.d.ts +34 -0
  104. package/src/lib/jobs/ai-job-plan-step.js +106 -14
  105. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  106. package/src/lib/jobs/ai-job-site-content.d.ts +81 -13
  107. package/src/lib/jobs/ai-job-site-content.js +210 -43
  108. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  109. package/src/lib/jobs/ai-job-site-step.d.ts +19 -0
  110. package/src/lib/jobs/ai-job-site-step.js +152 -51
  111. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  112. package/src/lib/jobs/ai-jobs.d.ts +8 -4
  113. package/src/lib/jobs/ai-jobs.js +19 -2
  114. package/src/lib/jobs/ai-jobs.js.map +1 -1
  115. package/src/lib/jobs/ai-layout-stock-photos.d.ts +98 -0
  116. package/src/lib/jobs/ai-layout-stock-photos.js +248 -0
  117. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -0
  118. package/src/lib/jobs/ai-site-publish.d.ts +11 -0
  119. package/src/lib/jobs/ai-site-publish.js +31 -1
  120. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  121. package/src/lib/jobs/fixtures/ai-juniper-portfolio-page-recording.d.ts +151 -0
  122. package/src/lib/jobs/fixtures/ai-juniper-portfolio-page-recording.js +183 -0
  123. package/src/lib/jobs/fixtures/ai-juniper-portfolio-page-recording.js.map +1 -0
  124. package/src/lib/jobs/fixtures/ai-slow-roads-site-plan-recording.d.ts +27 -0
  125. package/src/lib/jobs/fixtures/ai-slow-roads-site-plan-recording.js +192 -0
  126. package/src/lib/jobs/fixtures/ai-slow-roads-site-plan-recording.js.map +1 -0
  127. package/src/lib/jobs/fixtures/ai-yoga-site-plan-recording.d.ts +27 -0
  128. package/src/lib/jobs/fixtures/ai-yoga-site-plan-recording.js +222 -0
  129. package/src/lib/jobs/fixtures/ai-yoga-site-plan-recording.js.map +1 -0
  130. package/src/lib/layout-language/ai-layout-compiler.d.ts +37 -0
  131. package/src/lib/layout-language/ai-layout-compiler.js +1003 -19
  132. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  133. package/src/lib/layout-language/ai-layout-design.d.ts +114 -0
  134. package/src/lib/layout-language/ai-layout-design.js +209 -0
  135. package/src/lib/layout-language/ai-layout-design.js.map +1 -0
  136. package/src/lib/layout-language/ai-layout-frame.d.ts +12 -0
  137. package/src/lib/layout-language/ai-layout-frame.js +33 -8
  138. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  139. package/src/lib/layout-language/ai-layout-gaps.js +18 -2
  140. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  141. package/src/lib/layout-language/ai-layout-language.d.ts +2 -0
  142. package/src/lib/layout-language/ai-layout-language.js +15 -1
  143. package/src/lib/layout-language/ai-layout-language.js.map +1 -1
  144. package/src/lib/layout-language/ai-layout-links.d.ts +36 -0
  145. package/src/lib/layout-language/ai-layout-links.js +26 -2
  146. package/src/lib/layout-language/ai-layout-links.js.map +1 -1
  147. package/src/lib/layout-language/ai-layout-listings.d.ts +125 -0
  148. package/src/lib/layout-language/ai-layout-listings.js +187 -0
  149. package/src/lib/layout-language/ai-layout-listings.js.map +1 -0
  150. package/src/lib/layout-language/ai-layout-pictures.d.ts +11 -5
  151. package/src/lib/layout-language/ai-layout-pictures.js +80 -17
  152. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  153. package/src/lib/model/ai-job-notice.d.ts +9 -1
  154. package/src/lib/model/ai-job-notice.js +23 -0
  155. package/src/lib/model/ai-job-notice.js.map +1 -1
  156. package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
  157. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  158. package/src/lib/model/ai-site-build-progress.d.ts +12 -2
  159. package/src/lib/model/ai-site-build-progress.js +104 -15
  160. package/src/lib/model/ai-site-build-progress.js.map +1 -1
  161. package/src/lib/model/ai-site-job.d.ts +236 -24
  162. package/src/lib/model/ai-site-job.js +269 -22
  163. package/src/lib/model/ai-site-job.js.map +1 -1
  164. package/src/lib/model/ai-site-kinds.d.ts +30 -8
  165. package/src/lib/model/ai-site-kinds.js +735 -116
  166. package/src/lib/model/ai-site-kinds.js.map +1 -1
  167. package/src/lib/model/ai-site-look.d.ts +21 -0
  168. package/src/lib/model/ai-site-look.js +63 -14
  169. package/src/lib/model/ai-site-look.js.map +1 -1
  170. package/src/lib/model/ai-site-start-seo.d.ts +30 -0
  171. package/src/lib/model/ai-site-start-seo.js +132 -11
  172. package/src/lib/model/ai-site-start-seo.js.map +1 -1
  173. package/src/lib/model/ai-site-start.d.ts +2 -2
  174. package/src/lib/model/ai-site-start.js +3 -3
  175. package/src/lib/model/ai-site-start.js.map +1 -1
  176. package/src/lib/plugin.js +15 -3
  177. package/src/lib/plugin.js.map +1 -1
  178. package/src/lib/runtime/ai-doctrine-validators.d.ts +32 -1
  179. package/src/lib/runtime/ai-doctrine-validators.js +227 -4
  180. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  181. package/src/lib/runtime/ai-doctrine.js +5 -3
  182. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  183. package/src/lib/runtime/ai-palette.generated.d.ts +2 -0
  184. package/src/lib/runtime/ai-palette.generated.js +31 -5
  185. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  186. package/src/lib/server/ai-admin-conversations.d.ts +9 -0
  187. package/src/lib/server/ai-admin-conversations.js +151 -9
  188. package/src/lib/server/ai-admin-conversations.js.map +1 -1
  189. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  190. package/src/lib/server/ai-jobs-route.js +15 -3
  191. package/src/lib/server/ai-jobs-route.js.map +1 -1
  192. package/src/lib/server/assist-edit.js +5 -2
  193. package/src/lib/server/assist-edit.js.map +1 -1
  194. package/src/lib/usage/free-ai-credits-left.d.ts +54 -0
  195. package/src/lib/usage/free-ai-credits-left.js +62 -0
  196. package/src/lib/usage/free-ai-credits-left.js.map +1 -0
  197. package/src/lib/usage/staff-org-ai-conversations.d.ts +23 -0
  198. package/src/lib/usage/staff-org-ai-conversations.js +45 -0
  199. package/src/lib/usage/staff-org-ai-conversations.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start-seo.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 { SCREEN_SEO_TEXT_GUIDANCE } from '@aglyn/aglyn/app-utils/screen-seo-fields'\nimport type { AiJobOutput } from './ai-jobs.types'\nimport { aiSiteWords, type AiSiteWords } from './ai-site-job'\n\n/**\n * The site's own search listing, from the answers (AGL-2918).\n *\n * A site has two SEO title-and-description pairs. A page's is the page's, and\n * the scaffold's page step writes one for each page it builds. The SITE's is\n * the fallback every page with none of its own publishes, and it is the one\n * nothing was filling: a newly created site carries whatever its creation\n * seeded, which is its subdomain or its display name, and a scaffolded site\n * would publish pages listed under that.\n *\n * ── Derived, not generated ───────────────────────────────────────────────\n *\n * The two values here are arithmetic on what the person typed, not a model's\n * writing, and that is deliberate on three counts. The answers are already\n * the sentence the fallback wants — \"what kind of site\" IS what the site is,\n * and \"who it is for\" IS who it is for. It costs nothing, on a job that is\n * already tens of model calls. And it is available on the scaffold's first\n * pass, so the proposal is waiting while the pages are still building rather\n * than arriving after them.\n *\n * ── A proposal, never a write ────────────────────────────────────────────\n *\n * Nothing here writes the site's settings. The values travel as a job output\n * and land in the site SEO form as unsaved edits through the `hostSeo` zone's\n * own `proposeDraft`; the form's Update is what stores them, and a person who\n * never opens that form is left with exactly what they had.\n *\n * Pure: strings in, strings out, no Firestore and no React.\n */\n\n/**\n * The site SEO form's field names, which are what a proposal is keyed by.\n *\n * The same two strings the host settings SEO section names its Title and\n * Description inputs. They are stated here rather than imported because the\n * form is the console's and this is a plugin: the zone's `proposeDraft`\n * ignores a key the form does not edit, so a name that drifted would stage\n * nothing rather than stage something wrong.\n */\nexport const AI_SITE_SEO_FORM_FIELDS = {\n title: 'seo.title',\n description: 'seo.description',\n} as const\n\nexport type AiSiteSeoFormField =\n (typeof AI_SITE_SEO_FORM_FIELDS)[keyof typeof AI_SITE_SEO_FORM_FIELDS]\n\n/**\n * What each value is held to: the lengths the SEO editors guide a listing to,\n * which are the same two the host settings form counts down from.\n */\nexport const AI_SITE_SEO_LIMITS = SCREEN_SEO_TEXT_GUIDANCE\n\n/** The kind an `seo` output carries when it is a guided start's site listing. */\nexport const AI_SITE_SEO_PROPOSAL_KIND = 'site-listing'\n\n/** The id the scaffold reports its one site listing under. */\nexport const AI_SITE_SEO_OUTPUT_ID = 'site:listing'\n\nexport interface AiSiteSeoProposal {\n kind: typeof AI_SITE_SEO_PROPOSAL_KIND\n /** Values for the site SEO form, by field name. */\n values: Record<AiSiteSeoFormField, string>\n /** Customer-safe lines about where the words came from. */\n notes: string[]\n}\n\n/** What the proposal says about itself wherever it is shown. */\nexport const AI_SITE_SEO_NOTE =\n 'Written from what you said the site is and who it is for, not from its pages — read it before you save it.'\n\n/**\n * Words a phrase never ends on (AGL-3596): a title cut after \"for\" or \"that\"\n * reads as broken mid-sentence, which a search result shows to everybody.\n */\nconst DANGLING = new Set([\n 'a', 'an', 'and', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'so',\n 'that', 'the', 'to', 'which', 'who', 'with', 'where', 'whose', 'while', 'your', 'our', '—', '-', '–', '&',\n])\n\n/** Without the words a phrase never ends on, or the punctuation before them. */\nfunction withoutDangling(text: string): string {\n const words = text.split(' ')\n while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop()\n return words.join(' ').replace(/[\\s,;:—–-]+$/, '').trim()\n}\n\n/** Held to a length, cut at a word rather than mid-word, and never on a dangling word. */\nfunction clip(value: string, max: number): string {\n const text = value.replace(/\\s+/g, ' ').trim()\n if (text.length <= max) return text\n const cut = text.slice(0, max + 1)\n const space = cut.lastIndexOf(' ')\n return withoutDangling((space > max / 2 ? cut.slice(0, space) : text.slice(0, max)).trim())\n}\n\n/**\n * Where the first clause of an answer ends: \"a dog groomer in Austin THAT\n * takes bookings for…\" is a subject and then a sentence about it. A title\n * keeps the subject.\n */\nconst CLAUSE_BOUNDARY =\n /\\s+(?:that|which|who|whose|where|offering|providing|specializing|serving|helping|so|because|with|for|to|by|and|but)\\b|[,;:(—–]|\\s-\\s/i\n\n/**\n * A short search title (AGL-3596): the business's name, what it is and where,\n * held to the title cap and never cut mid-phrase. The answer to \"what kind of\n * site\" is often a whole sentence, and cutting that sentence at sixty\n * characters published \"…that takes bookings for\" as a site's title.\n *\n * The longest of these that fits wins: the whole answer; its first clause\n * (the subject before \"that\", \"for\", a comma…); that clause without its\n * place. The city the person gave is added where the words do not already\n * say it, and the name leads where there is one. Only when even the shortest\n * does not fit is it cut, at a word, never on a dangling one.\n */\nexport function aiSiteSeoTitle(input: { name?: string; subject: string; city?: string }): string {\n const max = AI_SITE_SEO_LIMITS.title\n const name = (input.name ?? '').replace(/\\s+/g, ' ').trim()\n const subject = withoutDangling(input.subject.replace(/\\s+/g, ' ').trim())\n const city = (input.city ?? '').replace(/\\s+/g, ' ').trim()\n const placed = (what: string) =>\n city && !what.toLowerCase().includes(city.toLowerCase()) ? `${what} in ${city}` : what\n const boundary = subject.search(CLAUSE_BOUNDARY)\n const clause = withoutDangling(boundary > 0 ? subject.slice(0, boundary) : subject)\n const bare = withoutDangling(clause.replace(/\\s+in\\s+[^]*$/i, '')) || clause\n const whats = [...new Set([placed(subject), subject, placed(clause), clause, bare])]\n const candidates = name ? [...whats.map((what) => `${name} — ${what}`), name] : whats\n const fits = candidates.find((candidate) => candidate.length <= max)\n return fits ?? clip(candidates[candidates.length - 1], max)\n}\n\n/**\n * Where a phrase may be cut without leaving half a thought (AGL-3596): before\n * a relative clause, or before a \"with\" or \"for\" phrase, or at a semicolon or\n * dash. A bare comma is not one — \"a calm, gentle groom\" cut at its comma\n * ends on an adjective — and neither is \"and\", which joins a list.\n */\nconst PHRASE_BOUNDARY = /\\s+(?:that|which|who|whose|where|while|because|with|for|offering|providing|serving)\\b|[;(—–]|\\s-\\s/gi\n\n/** A phrase and its shorter forms, longest first: itself, then cut before each boundary, back to front. */\nfunction phraseForms(text: string): string[] {\n const forms = [text]\n const cuts = [...text.matchAll(PHRASE_BOUNDARY)].map((match) => match.index ?? -1).filter((at) => at > 0)\n for (const at of cuts.reverse()) {\n const form = withoutDangling(text.slice(0, at).trim())\n if (form && !forms.includes(form)) forms.push(form)\n }\n return forms\n}\n\n/**\n * The site's search description (AGL-3596): what the site is, then who it is\n * for, composed WITHIN the description cap at phrase boundaries rather than\n * cut at it. Cut at 155 characters, \"…for dog owners in Hillside and nearby\n * towns who want a calm, gentle\" went out as a site's description. So the\n * longest whole composition that fits wins: all of both; the audience without\n * its trailing clauses; what the site is alone; then what the site is, cut\n * back the same way. Only when not even the subject fits is it clipped at a\n * word, never on a dangling one.\n */\nexport function aiSiteSeoDescription(input: { name: string; subject: string; about: string; audience: string }): string {\n const max = AI_SITE_SEO_LIMITS.description\n const lead = (what: string) => (input.name ? `${input.name} is ${what}` : asSubject(what))\n const abouts = phraseForms(withoutDangling(input.about.replace(/[.!?]+$/, '')))\n const audiences = input.audience ? phraseForms(withoutDangling(input.audience.replace(/[.!?]+$/, ''))) : []\n for (const about of abouts) {\n for (const audience of audiences) {\n const text = sentence(`${lead(about)}, for ${audience}`)\n if (text.length <= max) return text\n }\n const text = sentence(lead(about))\n if (text.length <= max) return text\n }\n return clip(sentence(lead(abouts[abouts.length - 1] || input.subject)), max)\n}\n\n/**\n * The leading article dropped and the first letter raised: \"a neighborhood\n * dog groomer\" is how a person answers the question and is not how a title\n * starts. Anything the person already capitalized is left alone, so a name\n * typed as the site type survives.\n */\nfunction asSubject(about: string): string {\n // `\\b` rather than `\\s+`, so an answer that is ONLY an article leaves\n // nothing behind and the caller proposes nothing: a one-letter title is a\n // worse thing to stage into a required field than no proposal at all.\n const text = about.replace(/\\s+/g, ' ').trim().replace(/^(?:an?|the)\\b\\s*/i, '')\n if (!text) return ''\n return text[0].toUpperCase() + text.slice(1)\n}\n\n/** A sentence closed with one period, and never two. */\nfunction sentence(text: string): string {\n const trimmed = text.trim()\n return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`\n}\n\n/**\n * What the listing is built from: either answer may be missing, because the\n * guided start requires only the first of them.\n */\nexport interface AiSiteSeoInput extends Partial<AiSiteWords> {\n /** The site's own name where the job carries one; empty for a site named on its pages. */\n siteName?: string\n /** The city the person gave, where they gave one. */\n city?: string\n}\n\n/**\n * The site's fallback listing from the answers, or `null` when the answers\n * describe no site at all — a job with no `businessType` has nothing to\n * propose, and an empty proposal is worse than none.\n */\nexport function aiSiteSeoProposal(input: AiSiteSeoInput): AiSiteSeoProposal | null {\n const subject = asSubject(input.about ?? '')\n if (!subject) return null\n const name = (input.siteName ?? '').replace(/\\s+/g, ' ').trim()\n const audience = (input.audience ?? '').replace(/\\s+/g, ' ').trim()\n const title = aiSiteSeoTitle({ name, subject, city: input.city })\n const about = (input.about ?? '').replace(/\\s+/g, ' ').trim()\n const description = aiSiteSeoDescription({ name, subject, about, audience })\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes: [AI_SITE_SEO_NOTE],\n }\n}\n\n/** The proposal a job's inputs imply, for the step that reports it. */\nexport function aiSiteSeoProposalForInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n): AiSiteSeoProposal | null {\n const words = aiSiteWords(inputs)\n const businessName = inputs?.['businessName']\n const city = inputs?.['city']\n return aiSiteSeoProposal({\n ...words,\n ...(typeof businessName === 'string' ? { siteName: businessName } : {}),\n ...(typeof city === 'string' ? { city } : {}),\n })\n}\n\n/** Whether a value is a record the reader may look inside. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The site listing a job's outputs carry, or `null` when they carry none.\n *\n * Read defensively: an output is whatever a past release wrote, so a proposal\n * whose values are not two strings is treated as no proposal rather than\n * staged into somebody's live SEO form.\n */\nexport function aiSiteSeoProposalOf(\n outputs: readonly Pick<AiJobOutput, 'resource' | 'proposal'>[] | null | undefined,\n): AiSiteSeoProposal | null {\n for (const output of outputs ?? []) {\n if (output.resource !== 'seo') continue\n const proposal = output.proposal\n if (!isRecord(proposal) || proposal['kind'] !== AI_SITE_SEO_PROPOSAL_KIND) continue\n const values = isRecord(proposal['values']) ? proposal['values'] : {}\n const title = values[AI_SITE_SEO_FORM_FIELDS.title]\n const description = values[AI_SITE_SEO_FORM_FIELDS.description]\n if (typeof title !== 'string' || typeof description !== 'string') continue\n if (!title.trim() || !description.trim()) continue\n const notes = Array.isArray(proposal['notes'])\n ? proposal['notes'].filter((note): note is string => typeof note === 'string')\n : []\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes,\n }\n }\n return null\n}\n"],"names":["SCREEN_SEO_TEXT_GUIDANCE","aiSiteWords","AI_SITE_SEO_FORM_FIELDS","title","description","AI_SITE_SEO_LIMITS","AI_SITE_SEO_PROPOSAL_KIND","AI_SITE_SEO_OUTPUT_ID","AI_SITE_SEO_NOTE","DANGLING","Set","withoutDangling","text","words","split","length","has","toLowerCase","pop","join","replace","trim","clip","value","max","cut","slice","space","lastIndexOf","CLAUSE_BOUNDARY","aiSiteSeoTitle","input","name","subject","city","placed","what","includes","boundary","search","clause","bare","whats","candidates","map","fits","find","candidate","PHRASE_BOUNDARY","phraseForms","forms","cuts","matchAll","match","index","filter","at","reverse","form","push","aiSiteSeoDescription","lead","asSubject","abouts","about","audiences","audience","sentence","toUpperCase","trimmed","test","aiSiteSeoProposal","siteName","kind","values","notes","aiSiteSeoProposalForInputs","inputs","businessName","isRecord","Array","isArray","aiSiteSeoProposalOf","outputs","output","resource","proposal","note"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start-seo.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 { SCREEN_SEO_TEXT_GUIDANCE } from '@aglyn/aglyn/app-utils/screen-seo-fields'\nimport type { AiJobOutput } from './ai-jobs.types'\nimport { aiSiteWords, type AiSiteWords } from './ai-site-job'\n\n/**\n * The site's own search listing, from the answers (AGL-2918).\n *\n * A site has two SEO title-and-description pairs. A page's is the page's, and\n * the scaffold's page step writes one for each page it builds. The SITE's is\n * the fallback every page with none of its own publishes, and it is the one\n * nothing was filling: a newly created site carries whatever its creation\n * seeded, which is its subdomain or its display name, and a scaffolded site\n * would publish pages listed under that.\n *\n * ── Derived, not generated ───────────────────────────────────────────────\n *\n * The two values here are arithmetic on what the person typed, not a model's\n * writing, and that is deliberate on three counts. The answers are already\n * the sentence the fallback wants — \"what kind of site\" IS what the site is,\n * and \"who it is for\" IS who it is for. It costs nothing, on a job that is\n * already tens of model calls. And it is available on the scaffold's first\n * pass, so the proposal is waiting while the pages are still building rather\n * than arriving after them.\n *\n * ── A proposal, never a write ────────────────────────────────────────────\n *\n * Nothing here writes the site's settings. The values travel as a job output\n * and land in the site SEO form as unsaved edits through the `hostSeo` zone's\n * own `proposeDraft`; the form's Update is what stores them, and a person who\n * never opens that form is left with exactly what they had.\n *\n * Pure: strings in, strings out, no Firestore and no React.\n */\n\n/**\n * The site SEO form's field names, which are what a proposal is keyed by.\n *\n * The same two strings the host settings SEO section names its Title and\n * Description inputs. They are stated here rather than imported because the\n * form is the console's and this is a plugin: the zone's `proposeDraft`\n * ignores a key the form does not edit, so a name that drifted would stage\n * nothing rather than stage something wrong.\n */\nexport const AI_SITE_SEO_FORM_FIELDS = {\n title: 'seo.title',\n description: 'seo.description',\n} as const\n\nexport type AiSiteSeoFormField =\n (typeof AI_SITE_SEO_FORM_FIELDS)[keyof typeof AI_SITE_SEO_FORM_FIELDS]\n\n/**\n * What each value is held to: the lengths the SEO editors guide a listing to,\n * which are the same two the host settings form counts down from.\n */\nexport const AI_SITE_SEO_LIMITS = SCREEN_SEO_TEXT_GUIDANCE\n\n/** The kind an `seo` output carries when it is a guided start's site listing. */\nexport const AI_SITE_SEO_PROPOSAL_KIND = 'site-listing'\n\n/** The id the scaffold reports its one site listing under. */\nexport const AI_SITE_SEO_OUTPUT_ID = 'site:listing'\n\nexport interface AiSiteSeoProposal {\n kind: typeof AI_SITE_SEO_PROPOSAL_KIND\n /** Values for the site SEO form, by field name. */\n values: Record<AiSiteSeoFormField, string>\n /** Customer-safe lines about where the words came from. */\n notes: string[]\n}\n\n/** What the proposal says about itself wherever it is shown. */\nexport const AI_SITE_SEO_NOTE =\n 'Written from what you said the site is and who it is for, not from its pages — read it before you save it.'\n\n/**\n * Words a phrase never ends on (AGL-3596): a title cut after \"for\" or \"that\"\n * reads as broken mid-sentence, which a search result shows to everybody.\n */\nconst DANGLING = new Set([\n 'a', 'an', 'and', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'so',\n 'that', 'the', 'to', 'which', 'who', 'with', 'where', 'whose', 'while', 'your', 'our', '—', '-', '–', '&',\n])\n\n/** Without the words a phrase never ends on, or the punctuation before them. */\nfunction withoutDangling(text: string): string {\n const words = text.split(' ')\n while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop()\n return words.join(' ').replace(/[\\s,;:—–-]+$/, '').trim()\n}\n\n/** Held to a length, cut at a word rather than mid-word, and never on a dangling word. */\nfunction clip(value: string, max: number): string {\n const text = value.replace(/\\s+/g, ' ').trim()\n if (text.length <= max) return text\n const cut = text.slice(0, max + 1)\n const space = cut.lastIndexOf(' ')\n return withoutDangling((space > max / 2 ? cut.slice(0, space) : text.slice(0, max)).trim())\n}\n\n/**\n * Where the first clause of an answer ends: \"a dog groomer in Austin THAT\n * takes bookings for…\" is a subject and then a sentence about it. A title\n * keeps the subject.\n */\nconst CLAUSE_BOUNDARY =\n /\\s+(?:that|which|who|whose|where|offering|providing|specializing|serving|helping|so|because|with|for|to|by|and|but)\\b|[,;:(—–]|\\s-\\s/i\n\n/**\n * A short search title (AGL-3596): the business's name, what it is and where,\n * held to the title cap and never cut mid-phrase. The answer to \"what kind of\n * site\" is often a whole sentence, and cutting that sentence at sixty\n * characters published \"…that takes bookings for\" as a site's title.\n *\n * The longest of these that fits wins: the whole answer; its first clause\n * (the subject before \"that\", \"for\", a comma…); that clause without its\n * place. The city the person gave is added where the words do not already\n * say it, and the name leads where there is one. Only when even the shortest\n * does not fit is it cut, at a word, never on a dangling one.\n */\nexport function aiSiteSeoTitle(input: { name?: string; subject: string; city?: string }): string {\n const max = AI_SITE_SEO_LIMITS.title\n const name = (input.name ?? '').replace(/\\s+/g, ' ').trim()\n const subject = withoutDangling(input.subject.replace(/\\s+/g, ' ').trim())\n const city = (input.city ?? '').replace(/\\s+/g, ' ').trim()\n const placed = (what: string) =>\n city && !what.toLowerCase().includes(city.toLowerCase()) ? `${what} in ${city}` : what\n const boundary = subject.search(CLAUSE_BOUNDARY)\n const clause = withoutDangling(boundary > 0 ? subject.slice(0, boundary) : subject)\n const bare = withoutDangling(clause.replace(/\\s+in\\s+[^]*$/i, '')) || clause\n const whats = [...new Set([placed(subject), subject, placed(clause), clause, bare])]\n const candidates = name ? [...whats.map((what) => `${name} — ${what}`), name] : whats\n const fits = candidates.find((candidate) => candidate.length <= max)\n return fits ?? clip(candidates[candidates.length - 1], max)\n}\n\n/**\n * Where a phrase may be cut without leaving half a thought (AGL-3596): before\n * a relative clause, or before a \"with\" or \"for\" phrase, or at a semicolon or\n * dash. A bare comma is not one — \"a calm, gentle groom\" cut at its comma\n * ends on an adjective — and neither is \"and\", which joins a list.\n */\nconst PHRASE_BOUNDARY = /\\s+(?:that|which|who|whose|where|while|because|with|for|offering|providing|serving)\\b|[;(—–]|\\s-\\s/gi\n\n/** A phrase and its shorter forms, longest first: itself, then cut before each boundary, back to front. */\nfunction phraseForms(text: string): string[] {\n const forms = [text]\n const cuts = [...text.matchAll(PHRASE_BOUNDARY)].map((match) => match.index ?? -1).filter((at) => at > 0)\n for (const at of cuts.reverse()) {\n const form = withoutDangling(text.slice(0, at).trim())\n if (form && !forms.includes(form)) forms.push(form)\n }\n return forms\n}\n\n/**\n * The site's search description (AGL-3596): what the site is, then who it is\n * for, composed WITHIN the description cap at phrase boundaries rather than\n * cut at it. Cut at 155 characters, \"…for dog owners in Hillside and nearby\n * towns who want a calm, gentle\" went out as a site's description. So the\n * longest whole composition that fits wins: all of both; the audience without\n * its trailing clauses; what the site is alone; then what the site is, cut\n * back the same way. Only when not even the subject fits is it clipped at a\n * word, never on a dangling one.\n *\n * It reads as one sentence (AGL-3660). The answers are set into it as\n * phrases, not pasted as the person typed them: a real job published\n * \"Eastside Book Circle is A neighborhood book club: monthly meetups, the\n * current read, past picks, and a way to join, for Neighbors on the east\n * side.\" So what the site is stops at a colon (`aiSiteSeoNounPhrase`), each\n * answer set inside the sentence starts in lower case where the person only\n * capitalized it because it began their answer (`aiSiteSeoEmbedded`), and\n * one thing takes an article (\"is a neighborhood book club\"). A plain noun\n * phrase runs straight on into who it is for; one with clauses of its own\n * keeps the comma, so \"for\" is not read as part of its last clause.\n */\nexport function aiSiteSeoDescription(input: {\n name: string\n subject: string\n about: string\n audience: string\n /** The city the person gave, never lowered where an answer starts with it. */\n city?: string\n}): string {\n const max = AI_SITE_SEO_LIMITS.description\n const keep = input.city ? [input.city] : []\n const lead = (what: string) =>\n input.name ? `${input.name} is ${withArticle(aiSiteSeoEmbedded(what, keep))}` : asSubject(what)\n const abouts = phraseForms(withoutDangling(aiSiteSeoNounPhrase(input.about).replace(/[.!?]+$/, '')))\n const audience = withoutDangling(input.audience.replace(/[.!?]+$/, '').replace(/^for\\s+/i, ''))\n const audiences = audience ? phraseForms(audience).map((form) => aiSiteSeoEmbedded(form, keep)) : []\n for (const about of abouts) {\n const joint = /[,;(—–]|\\s-\\s/.test(about) || about.search(PHRASE_BOUNDARY_ONE) > 0 ? ', for ' : ' for '\n for (const who of audiences) {\n const text = sentence(`${lead(about)}${joint}${who}`)\n if (text.length <= max) return text\n }\n const text = sentence(lead(about))\n if (text.length <= max) return text\n }\n return clip(sentence(lead(abouts[abouts.length - 1] || input.subject)), max)\n}\n\n/** {@link PHRASE_BOUNDARY} without the global flag, for a one-off search. */\nconst PHRASE_BOUNDARY_ONE = new RegExp(PHRASE_BOUNDARY.source, 'i')\n\n/**\n * What kind of site it is, as a short noun phrase (AGL-3660): the answer up\n * to a colon, since what follows one is a list of what the site will hold\n * (\"A neighborhood book club: monthly meetups, the current read, …\"), which\n * is the pages' to say, not the listing's. An answer that is nothing but its\n * list is kept whole.\n */\nexport function aiSiteSeoNounPhrase(about: string): string {\n const text = about.replace(/\\s+/g, ' ').trim()\n const colon = text.indexOf(':')\n const head = colon > 0 ? withoutDangling(text.slice(0, colon).trim()) : ''\n return head || text\n}\n\n/**\n * Words an answer opens with that are lowered when it is set inside a\n * sentence: articles and determiners, and the words a \"who is it for\" answer\n * most often opens with. A word not listed is lowered too where the answer\n * reads in sentence case — its next word in lower case — since that capital\n * only began the person's answer.\n */\nconst LOWERED_LEADS = new Set([\n 'a', 'an', 'the', 'our', 'my', 'your', 'their', 'this', 'any', 'all', 'every', 'some', 'one',\n 'anyone', 'everyone', 'people', 'folks', 'neighbors', 'neighbours', 'families', 'parents', 'kids',\n 'children', 'adults', 'teens', 'students', 'seniors', 'couples', 'women', 'men', 'locals', 'residents',\n 'homeowners', 'owners', 'members', 'readers', 'customers', 'clients', 'businesses', 'professionals',\n 'local', 'small', 'busy', 'new', 'young', 'independent', 'family', 'neighborhood', 'neighbourhood',\n])\n\n/**\n * An answer as a phrase inside a sentence (AGL-3660): its first word in lower\n * case where the person capitalized it only because it began their answer —\n * \"Neighbors on the east side\" is \"neighbors on the east side\", \"A\n * neighborhood book club\" is \"a neighborhood book club\". A word typed with\n * capitals of its own (\"McKinney\", \"NYC\"), a city the person gave, and a\n * capitalized word followed by another (\"Eastside Book Club\") are left alone.\n */\nexport function aiSiteSeoEmbedded(phrase: string, keep: readonly string[] = []): string {\n const text = phrase.replace(/\\s+/g, ' ').trim()\n const [first = '', second = ''] = text.split(' ')\n if (!/^[A-Z][a-z'’]*$/.test(first)) return text\n if (keep.some((word) => word && text.toLowerCase().startsWith(word.toLowerCase()))) return text\n const listed = LOWERED_LEADS.has(first.toLowerCase())\n const sentenceCase = !second || /^[a-z0-9]/.test(second)\n if (!listed && !sentenceCase) return text\n return first.toLowerCase() + text.slice(first.length)\n}\n\n/** Determiners an embedded noun phrase may already open with. */\nconst DETERMINERS = /^(?:a|an|the|our|my|your|their|this|one|any|every|each)\\b/i\n\n/**\n * A noun phrase behind \"is\" (AGL-3660): \"is a neighborhood book club\", never\n * \"is neighborhood book club\". A phrase that opens with its own determiner is\n * kept, and one whose head is plural (\"yoga classes and workshops\") takes\n * none.\n */\nfunction withArticle(phrase: string): string {\n if (!phrase || DETERMINERS.test(phrase)) return phrase\n const boundary = phrase.search(PHRASE_BOUNDARY_ONE)\n const head = (boundary > 0 ? phrase.slice(0, boundary) : phrase).split(/[\\s,]+/).filter(Boolean).pop() ?? ''\n if (/[^su]s$/i.test(head) && !/ics$/i.test(head)) return phrase\n return `${/^(?:[aeio]|u(?!ni|s[eu]))/i.test(phrase) ? 'an' : 'a'} ${phrase}`\n}\n\n/**\n * The leading article dropped and the first letter raised: \"a neighborhood\n * dog groomer\" is how a person answers the question and is not how a title\n * starts. Anything the person already capitalized is left alone, so a name\n * typed as the site type survives.\n */\nfunction asSubject(about: string): string {\n // `\\b` rather than `\\s+`, so an answer that is ONLY an article leaves\n // nothing behind and the caller proposes nothing: a one-letter title is a\n // worse thing to stage into a required field than no proposal at all.\n const text = about.replace(/\\s+/g, ' ').trim().replace(/^(?:an?|the)\\b\\s*/i, '')\n if (!text) return ''\n return text[0].toUpperCase() + text.slice(1)\n}\n\n/** A sentence closed with one period, and never two. */\nfunction sentence(text: string): string {\n const trimmed = text.trim()\n return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`\n}\n\n/**\n * What the listing is built from: either answer may be missing, because the\n * guided start requires only the first of them.\n */\nexport interface AiSiteSeoInput extends Partial<AiSiteWords> {\n /** The site's own name where the job carries one; empty for a site named on its pages. */\n siteName?: string\n /** The city the person gave, where they gave one. */\n city?: string\n}\n\n/**\n * The site's fallback listing from the answers, or `null` when the answers\n * describe no site at all — a job with no `businessType` has nothing to\n * propose, and an empty proposal is worse than none.\n */\nexport function aiSiteSeoProposal(input: AiSiteSeoInput): AiSiteSeoProposal | null {\n // What the site is, up to a colon (AGL-3660): the list after one belongs to neither value.\n const about = aiSiteSeoNounPhrase(input.about ?? '')\n const subject = asSubject(about)\n if (!subject) return null\n const name = (input.siteName ?? '').replace(/\\s+/g, ' ').trim()\n const audience = (input.audience ?? '').replace(/\\s+/g, ' ').trim()\n const city = (input.city ?? '').replace(/\\s+/g, ' ').trim()\n const title = aiSiteSeoTitle({ name, subject, city })\n const description = aiSiteSeoDescription({ name, subject, about, audience, ...(city ? { city } : {}) })\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes: [AI_SITE_SEO_NOTE],\n }\n}\n\n/** The proposal a job's inputs imply, for the step that reports it. */\nexport function aiSiteSeoProposalForInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n): AiSiteSeoProposal | null {\n const words = aiSiteWords(inputs)\n const businessName = inputs?.['businessName']\n const city = inputs?.['city']\n return aiSiteSeoProposal({\n ...words,\n ...(typeof businessName === 'string' ? { siteName: businessName } : {}),\n ...(typeof city === 'string' ? { city } : {}),\n })\n}\n\n/** Whether a value is a record the reader may look inside. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The site listing a job's outputs carry, or `null` when they carry none.\n *\n * Read defensively: an output is whatever a past release wrote, so a proposal\n * whose values are not two strings is treated as no proposal rather than\n * staged into somebody's live SEO form.\n */\nexport function aiSiteSeoProposalOf(\n outputs: readonly Pick<AiJobOutput, 'resource' | 'proposal'>[] | null | undefined,\n): AiSiteSeoProposal | null {\n for (const output of outputs ?? []) {\n if (output.resource !== 'seo') continue\n const proposal = output.proposal\n if (!isRecord(proposal) || proposal['kind'] !== AI_SITE_SEO_PROPOSAL_KIND) continue\n const values = isRecord(proposal['values']) ? proposal['values'] : {}\n const title = values[AI_SITE_SEO_FORM_FIELDS.title]\n const description = values[AI_SITE_SEO_FORM_FIELDS.description]\n if (typeof title !== 'string' || typeof description !== 'string') continue\n if (!title.trim() || !description.trim()) continue\n const notes = Array.isArray(proposal['notes'])\n ? proposal['notes'].filter((note): note is string => typeof note === 'string')\n : []\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes,\n }\n }\n return null\n}\n"],"names":["SCREEN_SEO_TEXT_GUIDANCE","aiSiteWords","AI_SITE_SEO_FORM_FIELDS","title","description","AI_SITE_SEO_LIMITS","AI_SITE_SEO_PROPOSAL_KIND","AI_SITE_SEO_OUTPUT_ID","AI_SITE_SEO_NOTE","DANGLING","Set","withoutDangling","text","words","split","length","has","toLowerCase","pop","join","replace","trim","clip","value","max","cut","slice","space","lastIndexOf","CLAUSE_BOUNDARY","aiSiteSeoTitle","input","name","subject","city","placed","what","includes","boundary","search","clause","bare","whats","candidates","map","fits","find","candidate","PHRASE_BOUNDARY","phraseForms","forms","cuts","matchAll","match","index","filter","at","reverse","form","push","aiSiteSeoDescription","keep","lead","withArticle","aiSiteSeoEmbedded","asSubject","abouts","aiSiteSeoNounPhrase","about","audience","audiences","joint","test","PHRASE_BOUNDARY_ONE","who","sentence","RegExp","source","colon","indexOf","head","LOWERED_LEADS","phrase","first","second","some","word","startsWith","listed","sentenceCase","DETERMINERS","Boolean","toUpperCase","trimmed","aiSiteSeoProposal","siteName","kind","values","notes","aiSiteSeoProposalForInputs","inputs","businessName","isRecord","Array","isArray","aiSiteSeoProposalOf","outputs","output","resource","proposal","note"],"mappings":"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"}
@@ -102,8 +102,8 @@ export interface AiSiteStartAnswers {
102
102
  * thing a person should have to choose.
103
103
  */
104
104
  export declare const AI_SITE_START_ANSWERS: AiSiteStartAnswers;
105
- /** The kind of site the answers pick, else the one what the site is for suggests. */
106
- export declare function aiSiteStartKind(answers: Pick<AiSiteStartAnswers, 'kind' | 'siteType'>): AiSiteKind;
105
+ /** The kind of site the answers pick, else the one what the site is and who it is for suggest. */
106
+ export declare function aiSiteStartKind(answers: Pick<AiSiteStartAnswers, 'kind' | 'siteType'> & Partial<Pick<AiSiteStartAnswers, 'audience'>>): AiSiteKind;
107
107
  /** The example an answer names, or `null` when it names none this list knows. */
108
108
  export declare function aiSiteStartExample(answers: AiSiteStartAnswers): AiSiteStartExample | null;
109
109
  /**
@@ -80,9 +80,9 @@ import { AI_SITE_INPUT_MAX_CHARS, AI_SITE_SUBMISSIONS, aiSitePagesBand } from ".
80
80
  submissions: 'inbox',
81
81
  welcomeEmail: true
82
82
  };
83
- /** The kind of site the answers pick, else the one what the site is for suggests. */ export function aiSiteStartKind(answers) {
84
- var _aiSiteKind;
85
- return (_aiSiteKind = aiSiteKind(answers.kind)) != null ? _aiSiteKind : aiSiteKindFor(answers.siteType);
83
+ /** The kind of site the answers pick, else the one what the site is and who it is for suggest. */ export function aiSiteStartKind(answers) {
84
+ var _aiSiteKind, _answers_audience;
85
+ return (_aiSiteKind = aiSiteKind(answers.kind)) != null ? _aiSiteKind : aiSiteKindFor(answers.siteType, (_answers_audience = answers.audience) != null ? _answers_audience : '');
86
86
  }
87
87
  /** The example an answer names, or `null` when it names none this list knows. */ export function aiSiteStartExample(answers) {
88
88
  var _AI_SITE_START_EXAMPLES_find;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start.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 { aiSiteKind, aiSiteKindFor, type AiSiteKind } from './ai-site-kinds'\nimport {\n AI_SITE_INPUT_MAX_CHARS,\n AI_SITE_SUBMISSIONS,\n aiSitePagesBand,\n type AiSiteJobInputs,\n type AiSiteSubmissions,\n} from './ai-site-job'\n\n/**\n * The guided start (AGL-2918): the questions a person answers on a site they\n * have just made, and the site job those answers become.\n *\n * A scaffold already exists — the `site` job plans four to eight pages, a\n * layout, a contact form, a palette and a welcome email, and builds every one\n * of them as a draft. What it had no way in through was a brief: somebody had\n * to know what to write, on a site where nothing has happened yet. So this\n * module is the QUESTIONS, and nothing else: what is asked, what an answer may\n * be, and how a set of answers becomes the brief and the scalar inputs the\n * `site` door already takes.\n *\n * ── Answering is never the way out ───────────────────────────────────────\n *\n * There is no answer here that means \"not this\". The way past the questions\n * is the `hostFirstRun` zone's own `startBlank`, which is drawn beside them\n * from the first one and creates nothing at all — see the zone in\n * `CONSOLE_WIDGET_SLOTS`. Putting the exit inside the question set would make\n * leaving a thing you answer your way to, and it is not.\n *\n * ── Every answer but the first is optional ───────────────────────────────\n *\n * Only what a site is FOR has no default worth guessing. The audience narrows\n * the copy, the example steers the shape, the page count and the welcome\n * email are defaults a person may change — so a person who types six words\n * and confirms gets a planned site, and the rest of the questions are there\n * for the person who wants to answer them.\n *\n * Pure: shapes, vocabularies and two string builders. No React, no network,\n * and no import of the starter catalog — {@link AI_SITE_START_EXAMPLES} names\n * the starters rather than reading them, so the console does not carry a\n * catalog of node maps to draw five cards. `ai-site-start.spec.ts` holds the\n * two lists to each other.\n */\n\n/** A worked example a person picks to steer what the site should look like. */\nexport interface AiSiteStartExample {\n /** A `STARTER_TEMPLATES` id, which is a persisted identifier. */\n id: string\n /** The starter as its card names it. */\n label: string\n /** The starter's category, which is how the examples group. */\n category: string\n /** What the example is, in one line a person reads before picking it. */\n blurb: string\n}\n\n/**\n * The examples the start offers, in the order they are shown. The platform's\n * own starter sites: a person who has no words for what they want has seen\n * these five shapes before, and picking one says more than a sentence would.\n */\nexport const AI_SITE_START_EXAMPLES: readonly AiSiteStartExample[] = [\n {\n id: 'business',\n label: 'Business',\n category: 'Business',\n blurb: 'A home page led by what you do, an about page and a contact page',\n },\n {\n id: 'landing',\n label: 'Landing Page',\n category: 'Marketing',\n blurb: 'One page that leads to one action, with the reasons to take it',\n },\n {\n id: 'portfolio',\n label: 'Portfolio',\n category: 'Personal',\n blurb: 'An introduction, a grid of work and a way to get in touch',\n },\n {\n id: 'physical-shop',\n label: 'Shop (physical products)',\n category: 'Commerce',\n blurb: 'Featured products, a shop to browse, and a cart',\n },\n {\n id: 'digital-shop',\n label: 'Shop (digital products)',\n category: 'Commerce',\n blurb: 'Downloads up front, a shop to browse, and a cart',\n },\n]\n\n/**\n * Kinds of site offered beside the first question's box, so answering it can\n * be a tap. They fill the box rather than replacing it: what somebody is\n * building is theirs to say, and a list of eight is a list somebody is not on.\n */\nexport const AI_SITE_START_TYPES: readonly string[] = [\n 'a local service business',\n 'a restaurant or cafe',\n 'a professional practice',\n 'a trades business',\n 'a shop',\n 'a nonprofit',\n 'a personal portfolio',\n 'an event',\n]\n\n/** What a person answered; the shape the card holds and the brief is built from. */\nexport interface AiSiteStartAnswers {\n /** What kind of site this is. The one answer with no default. */\n siteType: string\n /** Who the site is for; empty when they did not say. */\n audience: string\n /** The example they liked, by {@link AiSiteStartExample.id}; `null` for none. */\n example: string | null\n /**\n * The kind of site they picked (AGL-3660), by its `AI_SITE_KINDS` id;\n * `null` until they pick one, when the kind their first answer suggests is\n * the one used ({@link aiSiteStartKind}).\n */\n kind: string | null\n /** How many pages to plan. */\n pages: number\n /** Where the contact form's submissions go. */\n submissions: AiSiteSubmissions\n /** Whether the scaffold also drafts a welcome email. */\n welcomeEmail: boolean\n}\n\n/**\n * What the questions start on. Five pages because it is the middle of the\n * band the scaffold builds, a welcome email because the scaffold drafts one\n * unless it is told not to, and the Inbox because it is what every submission\n * does anyway — filing a lead as well is the addition, and an addition is the\n * thing a person should have to choose.\n */\nexport const AI_SITE_START_ANSWERS: AiSiteStartAnswers = {\n siteType: '',\n audience: '',\n example: null,\n kind: null,\n pages: 5,\n submissions: 'inbox',\n welcomeEmail: true,\n}\n\n/** The kind of site the answers pick, else the one what the site is for suggests. */\nexport function aiSiteStartKind(answers: Pick<AiSiteStartAnswers, 'kind' | 'siteType'>): AiSiteKind {\n return aiSiteKind(answers.kind) ?? aiSiteKindFor(answers.siteType)\n}\n\n/** The example an answer names, or `null` when it names none this list knows. */\nexport function aiSiteStartExample(\n answers: AiSiteStartAnswers,\n): AiSiteStartExample | null {\n if (!answers.example) return null\n return AI_SITE_START_EXAMPLES.find((entry) => entry.id === answers.example) ?? null\n}\n\n/**\n * Why these answers cannot start a site, in a sentence a person reads; `null`\n * when they can. The door's own parser is what actually admits the job — this\n * is what the card says before it asks for one.\n */\nexport function aiSiteStartRefusal(\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): string | null {\n // The workspace's own band (AGL-3594): one or two pages on the Free taste.\n const band = aiSitePagesBand(options.freeTaste)\n const siteType = answers.siteType.trim()\n if (!siteType) return 'Say what kind of site this is.'\n if (siteType.length > AI_SITE_INPUT_MAX_CHARS) {\n return `Keep what kind of site this is under ${AI_SITE_INPUT_MAX_CHARS} characters.`\n }\n if (answers.audience.trim().length > AI_SITE_INPUT_MAX_CHARS) {\n return `Keep who the site is for under ${AI_SITE_INPUT_MAX_CHARS} characters.`\n }\n if (!Number.isInteger(answers.pages) || answers.pages < band.min || answers.pages > band.max) {\n return `A site is planned with ${band.min} to ${band.max} pages.`\n }\n if (answers.example !== null && !aiSiteStartExample(answers)) {\n return 'Pick one of the examples, or none of them.'\n }\n if (!(AI_SITE_SUBMISSIONS as readonly string[]).includes(answers.submissions)) {\n return 'Say where the contact form’s submissions go.'\n }\n return null\n}\n\n/**\n * The answers as the brief the `site` job is created with: the plan step reads\n * it as the person's own words, so it says what they said and asks for nothing\n * they did not.\n */\nexport function aiSiteStartBrief(answers: AiSiteStartAnswers): string {\n const siteType = answers.siteType.trim()\n const audience = answers.audience.trim()\n const example = aiSiteStartExample(answers)\n const sentences = [\n `A ${answers.pages}-page website for ${siteType}.`,\n ]\n if (audience) sentences.push(`It is for ${audience}.`)\n if (example) {\n sentences.push(\n `Follow the shape of the ${example.label} starter: ${example.blurb.toLowerCase()}.`,\n )\n }\n // The kind they picked, or the one their answer suggests (AGL-3660).\n sentences.push(`Make it a ${aiSiteStartKind(answers).label.toLowerCase()} site.`)\n sentences.push(\n 'Plan a page for each thing a visitor comes to do, and a contact form on the page that asks to be contacted.',\n )\n if (audience) {\n // Said of the FORM as well as of the copy: who fills a form in is what\n // decides which fields it asks for, and a brief that named the audience\n // once, in a sentence about the pages, left the form to guess.\n sentences.push(`Ask the contact form for what ${audience} would be asked for.`)\n }\n return sentences.join(' ')\n}\n\n/**\n * The answers as the `site` job's scalar inputs. Read by the door's\n * {@link parseAiSiteJobInputs}, and put in front of the model one per line by\n * the plan step, which is how the audience and the example reach the plan\n * without a prompt of their own.\n *\n * The per-site variables an agency batch fills — the business name, the city,\n * the brand — are left empty here: the questions never ask them. The name is\n * the site's own, and the create door fills it from the site\n * (`aiJobAdmittedInputs`, AGL-3596) when the job is started — a job that\n * reached the model with no name had the model invent one for its header and\n * footer.\n */\nexport function aiSiteStartInputs(\n answers: AiSiteStartAnswers,\n): Omit<AiSiteJobInputs, 'batchId'> {\n const example = aiSiteStartExample(answers)\n const kind = aiSiteStartKind(answers)\n return {\n businessType: answers.siteType.trim(),\n audience: answers.audience.trim(),\n starter: example?.id ?? kind.starter,\n siteKind: kind.id,\n pages: answers.pages,\n businessName: '',\n city: '',\n brand: '',\n submissions: answers.submissions,\n welcomeEmail: answers.welcomeEmail,\n }\n}\n\n/** The site inputs a guided start's answers become, by name: what a summary carries back (AGL-3596). */\nexport const AI_SITE_START_INPUT_KEYS = [\n 'businessType',\n 'audience',\n 'starter',\n 'siteKind',\n 'pages',\n 'businessName',\n 'city',\n 'brand',\n 'submissions',\n 'welcomeEmail',\n] as const\n\n/** A site job's scalar inputs, and nothing else its inputs carry; `null` when it has none. */\nexport function aiSiteStartInputsOf(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n): Record<string, unknown> | null {\n if (!inputs) return null\n const kept: Record<string, unknown> = {}\n for (const key of AI_SITE_START_INPUT_KEYS) {\n const value = inputs[key]\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') kept[key] = value\n }\n return Object.keys(kept).length ? kept : null\n}\n\n/**\n * A failed start's answers, back as the questions hold them (AGL-3596), so\n * Try again reopens the guided start filled in for the person to adjust:\n * the inverse of {@link aiSiteStartInputs}. Read defensively — an answer the\n * questions no longer offer goes back to its default — and held to the\n * workspace's page band. `null` when the inputs say no kind of site at all.\n */\nexport function aiSiteStartAnswersFromInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n options: { freeTaste?: boolean } = {},\n): AiSiteStartAnswers | null {\n const text = (key: string) => (typeof inputs?.[key] === 'string' ? (inputs[key] as string).trim() : '')\n const siteType = text('businessType')\n if (!siteType) return null\n const band = aiSitePagesBand(options.freeTaste)\n const pages = typeof inputs?.['pages'] === 'number' ? Math.round(inputs['pages'] as number) : AI_SITE_START_ANSWERS.pages\n const starter = text('starter')\n const submissions = text('submissions')\n return {\n siteType,\n audience: text('audience'),\n example: AI_SITE_START_EXAMPLES.some((entry) => entry.id === starter) ? starter : null,\n kind: aiSiteKind(text('siteKind'))?.id ?? null,\n pages: Math.min(band.max, Math.max(band.min, pages)),\n submissions: (AI_SITE_SUBMISSIONS as readonly string[]).includes(submissions)\n ? (submissions as AiSiteSubmissions)\n : AI_SITE_START_ANSWERS.submissions,\n welcomeEmail: options.freeTaste ? false : inputs?.['welcomeEmail'] !== false,\n }\n}\n"],"names":["aiSiteKind","aiSiteKindFor","AI_SITE_INPUT_MAX_CHARS","AI_SITE_SUBMISSIONS","aiSitePagesBand","AI_SITE_START_EXAMPLES","id","label","category","blurb","AI_SITE_START_TYPES","AI_SITE_START_ANSWERS","siteType","audience","example","kind","pages","submissions","welcomeEmail","aiSiteStartKind","answers","aiSiteStartExample","find","entry","aiSiteStartRefusal","options","band","freeTaste","trim","length","Number","isInteger","min","max","includes","aiSiteStartBrief","sentences","push","toLowerCase","join","aiSiteStartInputs","businessType","starter","siteKind","businessName","city","brand","AI_SITE_START_INPUT_KEYS","aiSiteStartInputsOf","inputs","kept","key","value","Object","keys","aiSiteStartAnswersFromInputs","text","Math","round","some"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start.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 { aiSiteKind, aiSiteKindFor, type AiSiteKind } from './ai-site-kinds'\nimport {\n AI_SITE_INPUT_MAX_CHARS,\n AI_SITE_SUBMISSIONS,\n aiSitePagesBand,\n type AiSiteJobInputs,\n type AiSiteSubmissions,\n} from './ai-site-job'\n\n/**\n * The guided start (AGL-2918): the questions a person answers on a site they\n * have just made, and the site job those answers become.\n *\n * A scaffold already exists — the `site` job plans four to eight pages, a\n * layout, a contact form, a palette and a welcome email, and builds every one\n * of them as a draft. What it had no way in through was a brief: somebody had\n * to know what to write, on a site where nothing has happened yet. So this\n * module is the QUESTIONS, and nothing else: what is asked, what an answer may\n * be, and how a set of answers becomes the brief and the scalar inputs the\n * `site` door already takes.\n *\n * ── Answering is never the way out ───────────────────────────────────────\n *\n * There is no answer here that means \"not this\". The way past the questions\n * is the `hostFirstRun` zone's own `startBlank`, which is drawn beside them\n * from the first one and creates nothing at all — see the zone in\n * `CONSOLE_WIDGET_SLOTS`. Putting the exit inside the question set would make\n * leaving a thing you answer your way to, and it is not.\n *\n * ── Every answer but the first is optional ───────────────────────────────\n *\n * Only what a site is FOR has no default worth guessing. The audience narrows\n * the copy, the example steers the shape, the page count and the welcome\n * email are defaults a person may change — so a person who types six words\n * and confirms gets a planned site, and the rest of the questions are there\n * for the person who wants to answer them.\n *\n * Pure: shapes, vocabularies and two string builders. No React, no network,\n * and no import of the starter catalog — {@link AI_SITE_START_EXAMPLES} names\n * the starters rather than reading them, so the console does not carry a\n * catalog of node maps to draw five cards. `ai-site-start.spec.ts` holds the\n * two lists to each other.\n */\n\n/** A worked example a person picks to steer what the site should look like. */\nexport interface AiSiteStartExample {\n /** A `STARTER_TEMPLATES` id, which is a persisted identifier. */\n id: string\n /** The starter as its card names it. */\n label: string\n /** The starter's category, which is how the examples group. */\n category: string\n /** What the example is, in one line a person reads before picking it. */\n blurb: string\n}\n\n/**\n * The examples the start offers, in the order they are shown. The platform's\n * own starter sites: a person who has no words for what they want has seen\n * these five shapes before, and picking one says more than a sentence would.\n */\nexport const AI_SITE_START_EXAMPLES: readonly AiSiteStartExample[] = [\n {\n id: 'business',\n label: 'Business',\n category: 'Business',\n blurb: 'A home page led by what you do, an about page and a contact page',\n },\n {\n id: 'landing',\n label: 'Landing Page',\n category: 'Marketing',\n blurb: 'One page that leads to one action, with the reasons to take it',\n },\n {\n id: 'portfolio',\n label: 'Portfolio',\n category: 'Personal',\n blurb: 'An introduction, a grid of work and a way to get in touch',\n },\n {\n id: 'physical-shop',\n label: 'Shop (physical products)',\n category: 'Commerce',\n blurb: 'Featured products, a shop to browse, and a cart',\n },\n {\n id: 'digital-shop',\n label: 'Shop (digital products)',\n category: 'Commerce',\n blurb: 'Downloads up front, a shop to browse, and a cart',\n },\n]\n\n/**\n * Kinds of site offered beside the first question's box, so answering it can\n * be a tap. They fill the box rather than replacing it: what somebody is\n * building is theirs to say, and a list of eight is a list somebody is not on.\n */\nexport const AI_SITE_START_TYPES: readonly string[] = [\n 'a local service business',\n 'a restaurant or cafe',\n 'a professional practice',\n 'a trades business',\n 'a shop',\n 'a nonprofit',\n 'a personal portfolio',\n 'an event',\n]\n\n/** What a person answered; the shape the card holds and the brief is built from. */\nexport interface AiSiteStartAnswers {\n /** What kind of site this is. The one answer with no default. */\n siteType: string\n /** Who the site is for; empty when they did not say. */\n audience: string\n /** The example they liked, by {@link AiSiteStartExample.id}; `null` for none. */\n example: string | null\n /**\n * The kind of site they picked (AGL-3660), by its `AI_SITE_KINDS` id;\n * `null` until they pick one, when the kind their first answer suggests is\n * the one used ({@link aiSiteStartKind}).\n */\n kind: string | null\n /** How many pages to plan. */\n pages: number\n /** Where the contact form's submissions go. */\n submissions: AiSiteSubmissions\n /** Whether the scaffold also drafts a welcome email. */\n welcomeEmail: boolean\n}\n\n/**\n * What the questions start on. Five pages because it is the middle of the\n * band the scaffold builds, a welcome email because the scaffold drafts one\n * unless it is told not to, and the Inbox because it is what every submission\n * does anyway — filing a lead as well is the addition, and an addition is the\n * thing a person should have to choose.\n */\nexport const AI_SITE_START_ANSWERS: AiSiteStartAnswers = {\n siteType: '',\n audience: '',\n example: null,\n kind: null,\n pages: 5,\n submissions: 'inbox',\n welcomeEmail: true,\n}\n\n/** The kind of site the answers pick, else the one what the site is and who it is for suggest. */\nexport function aiSiteStartKind(\n answers: Pick<AiSiteStartAnswers, 'kind' | 'siteType'> & Partial<Pick<AiSiteStartAnswers, 'audience'>>,\n): AiSiteKind {\n return aiSiteKind(answers.kind) ?? aiSiteKindFor(answers.siteType, answers.audience ?? '')\n}\n\n/** The example an answer names, or `null` when it names none this list knows. */\nexport function aiSiteStartExample(\n answers: AiSiteStartAnswers,\n): AiSiteStartExample | null {\n if (!answers.example) return null\n return AI_SITE_START_EXAMPLES.find((entry) => entry.id === answers.example) ?? null\n}\n\n/**\n * Why these answers cannot start a site, in a sentence a person reads; `null`\n * when they can. The door's own parser is what actually admits the job — this\n * is what the card says before it asks for one.\n */\nexport function aiSiteStartRefusal(\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): string | null {\n // The workspace's own band (AGL-3594): one or two pages on the Free taste.\n const band = aiSitePagesBand(options.freeTaste)\n const siteType = answers.siteType.trim()\n if (!siteType) return 'Say what kind of site this is.'\n if (siteType.length > AI_SITE_INPUT_MAX_CHARS) {\n return `Keep what kind of site this is under ${AI_SITE_INPUT_MAX_CHARS} characters.`\n }\n if (answers.audience.trim().length > AI_SITE_INPUT_MAX_CHARS) {\n return `Keep who the site is for under ${AI_SITE_INPUT_MAX_CHARS} characters.`\n }\n if (!Number.isInteger(answers.pages) || answers.pages < band.min || answers.pages > band.max) {\n return `A site is planned with ${band.min} to ${band.max} pages.`\n }\n if (answers.example !== null && !aiSiteStartExample(answers)) {\n return 'Pick one of the examples, or none of them.'\n }\n if (!(AI_SITE_SUBMISSIONS as readonly string[]).includes(answers.submissions)) {\n return 'Say where the contact form’s submissions go.'\n }\n return null\n}\n\n/**\n * The answers as the brief the `site` job is created with: the plan step reads\n * it as the person's own words, so it says what they said and asks for nothing\n * they did not.\n */\nexport function aiSiteStartBrief(answers: AiSiteStartAnswers): string {\n const siteType = answers.siteType.trim()\n const audience = answers.audience.trim()\n const example = aiSiteStartExample(answers)\n const sentences = [\n `A ${answers.pages}-page website for ${siteType}.`,\n ]\n if (audience) sentences.push(`It is for ${audience}.`)\n if (example) {\n sentences.push(\n `Follow the shape of the ${example.label} starter: ${example.blurb.toLowerCase()}.`,\n )\n }\n // The kind they picked, or the one their answer suggests (AGL-3660).\n sentences.push(`Make it a ${aiSiteStartKind(answers).label.toLowerCase()} site.`)\n sentences.push(\n 'Plan a page for each thing a visitor comes to do, and a contact form on the page that asks to be contacted.',\n )\n if (audience) {\n // Said of the FORM as well as of the copy: who fills a form in is what\n // decides which fields it asks for, and a brief that named the audience\n // once, in a sentence about the pages, left the form to guess.\n sentences.push(`Ask the contact form for what ${audience} would be asked for.`)\n }\n return sentences.join(' ')\n}\n\n/**\n * The answers as the `site` job's scalar inputs. Read by the door's\n * {@link parseAiSiteJobInputs}, and put in front of the model one per line by\n * the plan step, which is how the audience and the example reach the plan\n * without a prompt of their own.\n *\n * The per-site variables an agency batch fills — the business name, the city,\n * the brand — are left empty here: the questions never ask them. The name is\n * the site's own, and the create door fills it from the site\n * (`aiJobAdmittedInputs`, AGL-3596) when the job is started — a job that\n * reached the model with no name had the model invent one for its header and\n * footer.\n */\nexport function aiSiteStartInputs(\n answers: AiSiteStartAnswers,\n): Omit<AiSiteJobInputs, 'batchId'> {\n const example = aiSiteStartExample(answers)\n const kind = aiSiteStartKind(answers)\n return {\n businessType: answers.siteType.trim(),\n audience: answers.audience.trim(),\n starter: example?.id ?? kind.starter,\n siteKind: kind.id,\n pages: answers.pages,\n businessName: '',\n city: '',\n brand: '',\n submissions: answers.submissions,\n welcomeEmail: answers.welcomeEmail,\n }\n}\n\n/** The site inputs a guided start's answers become, by name: what a summary carries back (AGL-3596). */\nexport const AI_SITE_START_INPUT_KEYS = [\n 'businessType',\n 'audience',\n 'starter',\n 'siteKind',\n 'pages',\n 'businessName',\n 'city',\n 'brand',\n 'submissions',\n 'welcomeEmail',\n] as const\n\n/** A site job's scalar inputs, and nothing else its inputs carry; `null` when it has none. */\nexport function aiSiteStartInputsOf(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n): Record<string, unknown> | null {\n if (!inputs) return null\n const kept: Record<string, unknown> = {}\n for (const key of AI_SITE_START_INPUT_KEYS) {\n const value = inputs[key]\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') kept[key] = value\n }\n return Object.keys(kept).length ? kept : null\n}\n\n/**\n * A failed start's answers, back as the questions hold them (AGL-3596), so\n * Try again reopens the guided start filled in for the person to adjust:\n * the inverse of {@link aiSiteStartInputs}. Read defensively — an answer the\n * questions no longer offer goes back to its default — and held to the\n * workspace's page band. `null` when the inputs say no kind of site at all.\n */\nexport function aiSiteStartAnswersFromInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n options: { freeTaste?: boolean } = {},\n): AiSiteStartAnswers | null {\n const text = (key: string) => (typeof inputs?.[key] === 'string' ? (inputs[key] as string).trim() : '')\n const siteType = text('businessType')\n if (!siteType) return null\n const band = aiSitePagesBand(options.freeTaste)\n const pages = typeof inputs?.['pages'] === 'number' ? Math.round(inputs['pages'] as number) : AI_SITE_START_ANSWERS.pages\n const starter = text('starter')\n const submissions = text('submissions')\n return {\n siteType,\n audience: text('audience'),\n example: AI_SITE_START_EXAMPLES.some((entry) => entry.id === starter) ? starter : null,\n kind: aiSiteKind(text('siteKind'))?.id ?? null,\n pages: Math.min(band.max, Math.max(band.min, pages)),\n submissions: (AI_SITE_SUBMISSIONS as readonly string[]).includes(submissions)\n ? (submissions as AiSiteSubmissions)\n : AI_SITE_START_ANSWERS.submissions,\n welcomeEmail: options.freeTaste ? false : inputs?.['welcomeEmail'] !== false,\n }\n}\n"],"names":["aiSiteKind","aiSiteKindFor","AI_SITE_INPUT_MAX_CHARS","AI_SITE_SUBMISSIONS","aiSitePagesBand","AI_SITE_START_EXAMPLES","id","label","category","blurb","AI_SITE_START_TYPES","AI_SITE_START_ANSWERS","siteType","audience","example","kind","pages","submissions","welcomeEmail","aiSiteStartKind","answers","aiSiteStartExample","find","entry","aiSiteStartRefusal","options","band","freeTaste","trim","length","Number","isInteger","min","max","includes","aiSiteStartBrief","sentences","push","toLowerCase","join","aiSiteStartInputs","businessType","starter","siteKind","businessName","city","brand","AI_SITE_START_INPUT_KEYS","aiSiteStartInputsOf","inputs","kept","key","value","Object","keys","aiSiteStartAnswersFromInputs","text","Math","round","some"],"mappings":"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"}
package/src/lib/plugin.js CHANGED
@@ -147,8 +147,8 @@ const AiJobsPage = lazyWidget('AiJobsPage', ()=>import("./components/ai-jobs-pag
147
147
  recordTitle: 'Building your site',
148
148
  header: {
149
149
  title: 'AI jobs',
150
- docsTopic: 'howAglynAiBuilds',
151
- docsAnchor: '#finding-your-ai-jobs'
150
+ docsTopic: 'aiJobsAndActivity',
151
+ docsAnchor: '#the-ai-jobs-page'
152
152
  },
153
153
  unlisted: true,
154
154
  ownsSubtree: true,
@@ -298,7 +298,7 @@ const AiJobsPage = lazyWidget('AiJobsPage', ()=>import("./components/ai-jobs-pag
298
298
  {
299
299
  slot: 'staffOrg',
300
300
  widgetId: 'ai-org-conversations',
301
- title: 'AI conversations',
301
+ title: `${PLATFORM_BRAND_NAME} AI requests`,
302
302
  Component: StaffOrgAiConversations
303
303
  },
304
304
  {
@@ -307,6 +307,14 @@ const AiJobsPage = lazyWidget('AiJobsPage', ()=>import("./components/ai-jobs-pag
307
307
  title: 'AI usage across organizations',
308
308
  Component: StaffUserAiUsageCard
309
309
  },
310
+ // The same card for one account, across every org it asked in
311
+ // (AGL-3660); the slot passes `uid`, which puts it in account mode.
312
+ {
313
+ slot: 'staffUser',
314
+ widgetId: 'ai-account-requests',
315
+ title: `${PLATFORM_BRAND_NAME} AI requests`,
316
+ Component: StaffOrgAiConversations
317
+ },
310
318
  // The staff tables' AI figures (AGL-2984): the Organizations list's
311
319
  // spend column, and the usage table's three columns with the credit
312
320
  // pool line the org page draws above it.
@@ -458,12 +466,16 @@ const AiJobsPage = lazyWidget('AiJobsPage', ()=>import("./components/ai-jobs-pag
458
466
  // media in the media library, and in its empty state. Gated as the
459
467
  // other generative widgets are, by the shell alone: it asks nothing of
460
468
  // a server until someone creates a picture, and the door decides then.
469
+ // Like every Create with AI entry it is mounted without the add-on too
470
+ // and opens its own upsell there (AGL-3601).
461
471
  {
462
472
  slot: 'mediaLibrary',
463
473
  widgetId: 'ai-media-create',
464
474
  title: 'Create images with AI',
465
475
  featureFlag: 'aiGenerative',
466
476
  permission: 'ai.generate',
477
+ showWhenNotEntitled: true,
478
+ releaseFlag: 'release_ai_generative',
467
479
  Component: AiMediaCreateButton
468
480
  },
469
481
  {