@aglyn/plugins-ai 1.0.0-beta.270 → 1.0.0-beta.272
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.
- package/package.json +17 -17
- package/src/lib/activity/ai-activity-actions.d.ts +4 -3
- package/src/lib/activity/ai-activity-actions.js +4 -0
- package/src/lib/activity/ai-activity-actions.js.map +1 -1
- package/src/lib/assets/site-styles/beauty.webp +0 -0
- package/src/lib/assets/site-styles/blog.webp +0 -0
- package/src/lib/assets/site-styles/business.webp +0 -0
- package/src/lib/assets/site-styles/coming-soon.webp +0 -0
- package/src/lib/assets/site-styles/education.webp +0 -0
- package/src/lib/assets/site-styles/events.webp +0 -0
- package/src/lib/assets/site-styles/fitness.webp +0 -0
- package/src/lib/assets/site-styles/landing.webp +0 -0
- package/src/lib/assets/site-styles/music.webp +0 -0
- package/src/lib/assets/site-styles/nonprofit.webp +0 -0
- package/src/lib/assets/site-styles/personal.webp +0 -0
- package/src/lib/assets/site-styles/photography.webp +0 -0
- package/src/lib/assets/site-styles/portfolio.webp +0 -0
- package/src/lib/assets/site-styles/professional.webp +0 -0
- package/src/lib/assets/site-styles/realestate.webp +0 -0
- package/src/lib/assets/site-styles/restaurant.webp +0 -0
- package/src/lib/assets/site-styles/store.webp +0 -0
- package/src/lib/assets/site-styles/studio.webp +0 -0
- package/src/lib/assets/site-styles/trades.webp +0 -0
- package/src/lib/assets/site-styles/wellness.webp +0 -0
- package/src/lib/assets/site-styles/yoga.webp +0 -0
- package/src/lib/components/ai-credits-card.component.d.ts +4 -3
- package/src/lib/components/ai-credits-card.component.js +15 -15
- package/src/lib/components/ai-credits-card.component.js.map +1 -1
- package/src/lib/components/ai-job-input-form.component.d.ts +36 -0
- package/src/lib/components/ai-job-input-form.component.js +100 -0
- package/src/lib/components/ai-job-input-form.component.js.map +1 -0
- package/src/lib/components/ai-job-requests.d.ts +4 -0
- package/src/lib/components/ai-job-requests.js +4 -0
- package/src/lib/components/ai-job-requests.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.js +18 -0
- package/src/lib/components/ai-site-build-page.component.js.map +1 -1
- package/src/lib/components/ai-site-import-job.component.d.ts +41 -0
- package/src/lib/components/ai-site-import-job.component.js +707 -0
- package/src/lib/components/ai-site-import-job.component.js.map +1 -0
- package/src/lib/components/ai-site-import-start.component.d.ts +64 -0
- package/src/lib/components/ai-site-import-start.component.js +335 -0
- package/src/lib/components/ai-site-import-start.component.js.map +1 -0
- package/src/lib/components/ai-site-ownership-proof.component.d.ts +42 -0
- package/src/lib/components/ai-site-ownership-proof.component.js +353 -0
- package/src/lib/components/ai-site-ownership-proof.component.js.map +1 -0
- package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
- package/src/lib/components/ai-site-start-card.component.js +261 -119
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-start-steps.component.d.ts +43 -2
- package/src/lib/components/ai-site-start-steps.component.js +651 -15
- package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
- package/src/lib/components/ai-site-start-tracking.d.ts +10 -0
- package/src/lib/components/ai-site-start-tracking.js +14 -0
- package/src/lib/components/ai-site-start-tracking.js.map +1 -1
- package/src/lib/components/ai-site-style-previews.js +26 -22
- package/src/lib/components/ai-site-style-previews.js.map +1 -1
- package/src/lib/components/ai-upsell-dialog.component.d.ts +7 -5
- package/src/lib/components/ai-upsell-dialog.component.js +8 -6
- package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
- package/src/lib/components/assist-build-card.component.js +11 -1
- package/src/lib/components/assist-build-card.component.js.map +1 -1
- package/src/lib/components/assist-jobs-drawer.component.js +9 -1
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +8 -4
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/staff-org-ai-card.component.js +4 -7
- package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
- package/src/lib/declarations.d.ts +2 -2
- package/src/lib/declarations.js +9 -5
- package/src/lib/declarations.js.map +1 -1
- package/src/lib/jobs/ai-build-capabilities.js +3 -1
- package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
- package/src/lib/jobs/ai-business-profile-prefill.d.ts +1 -1
- package/src/lib/jobs/ai-business-profile-prefill.js +14 -3
- package/src/lib/jobs/ai-business-profile-prefill.js.map +1 -1
- package/src/lib/jobs/ai-job-build-step.js +5 -3
- package/src/lib/jobs/ai-job-build-step.js.map +1 -1
- package/src/lib/jobs/ai-job-drafts.d.ts +14 -0
- package/src/lib/jobs/ai-job-drafts.js +24 -0
- package/src/lib/jobs/ai-job-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-form-step.d.ts +15 -1
- package/src/lib/jobs/ai-job-form-step.js +75 -8
- package/src/lib/jobs/ai-job-form-step.js.map +1 -1
- package/src/lib/jobs/ai-job-import-step.d.ts +136 -0
- package/src/lib/jobs/ai-job-import-step.js +809 -0
- package/src/lib/jobs/ai-job-import-step.js.map +1 -0
- package/src/lib/jobs/ai-job-page-language.d.ts +52 -1
- package/src/lib/jobs/ai-job-page-language.js +99 -5
- package/src/lib/jobs/ai-job-page-language.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.d.ts +19 -2
- package/src/lib/jobs/ai-job-page-step.js +42 -18
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +25 -2
- package/src/lib/jobs/ai-job-plan-step.js +103 -29
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-settings-step.js +4 -3
- package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-datasets.d.ts +27 -0
- package/src/lib/jobs/ai-job-site-datasets.js +140 -14
- package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
- package/src/lib/jobs/ai-job-site-look.js +18 -5
- package/src/lib/jobs/ai-job-site-look.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +16 -1
- package/src/lib/jobs/ai-job-site-step.js +95 -22
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +15 -1
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-job-tracking-step.d.ts +14 -3
- package/src/lib/jobs/ai-job-tracking-step.js +28 -5
- package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.d.ts +13 -1
- package/src/lib/jobs/ai-jobs.js +77 -10
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-layout-owner-photos.d.ts +16 -1
- package/src/lib/jobs/ai-layout-owner-photos.js +47 -0
- package/src/lib/jobs/ai-layout-owner-photos.js.map +1 -1
- package/src/lib/jobs/ai-layout-stock-photos.d.ts +27 -2
- package/src/lib/jobs/ai-layout-stock-photos.js +190 -19
- package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
- package/src/lib/jobs/ai-site-inspiration-signals.d.ts +98 -0
- package/src/lib/jobs/ai-site-inspiration-signals.js +585 -0
- package/src/lib/jobs/ai-site-inspiration-signals.js.map +1 -0
- package/src/lib/jobs/ai-site-inspiration.d.ts +157 -0
- package/src/lib/jobs/ai-site-inspiration.js +346 -0
- package/src/lib/jobs/ai-site-inspiration.js.map +1 -0
- package/src/lib/jobs/ai-site-publish.d.ts +26 -1
- package/src/lib/jobs/ai-site-publish.js +162 -13
- package/src/lib/jobs/ai-site-publish.js.map +1 -1
- package/src/lib/jobs/ai-theme-brand-inputs.d.ts +77 -3
- package/src/lib/jobs/ai-theme-brand-inputs.js +135 -31
- package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.d.ts +97 -0
- package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js +137 -0
- package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-site-inspiration-pages.d.ts +38 -0
- package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js +103 -0
- package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.d.ts +25 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js +174 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js.map +1 -0
- package/src/lib/layout-language/ai-layout-compiler.d.ts +1 -1
- package/src/lib/layout-language/ai-layout-compiler.js +438 -25
- package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
- package/src/lib/layout-language/ai-layout-contact.d.ts +57 -0
- package/src/lib/layout-language/ai-layout-contact.js +137 -0
- package/src/lib/layout-language/ai-layout-contact.js.map +1 -0
- package/src/lib/layout-language/ai-layout-copy.d.ts +12 -0
- package/src/lib/layout-language/ai-layout-copy.js +34 -2
- package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
- package/src/lib/layout-language/ai-layout-design.d.ts +65 -8
- package/src/lib/layout-language/ai-layout-design.js +411 -97
- package/src/lib/layout-language/ai-layout-design.js.map +1 -1
- package/src/lib/layout-language/ai-layout-frame.js +23 -0
- package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
- package/src/lib/layout-language/ai-layout-labels.d.ts +2 -0
- package/src/lib/layout-language/ai-layout-labels.js +12 -6
- package/src/lib/layout-language/ai-layout-labels.js.map +1 -1
- package/src/lib/layout-language/ai-layout-language.d.ts +3 -2
- package/src/lib/layout-language/ai-layout-language.js +4 -3
- package/src/lib/layout-language/ai-layout-language.js.map +1 -1
- package/src/lib/layout-language/ai-layout-links.d.ts +13 -0
- package/src/lib/layout-language/ai-layout-links.js +30 -0
- package/src/lib/layout-language/ai-layout-links.js.map +1 -1
- package/src/lib/layout-language/ai-layout-pictures.d.ts +49 -6
- package/src/lib/layout-language/ai-layout-pictures.js +145 -22
- package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
- package/src/lib/model/ai-build-job.d.ts +16 -1
- package/src/lib/model/ai-build-job.js +40 -0
- package/src/lib/model/ai-build-job.js.map +1 -1
- package/src/lib/model/ai-credit-estimate.d.ts +20 -0
- package/src/lib/model/ai-credit-estimate.js +13 -0
- package/src/lib/model/ai-credit-estimate.js.map +1 -1
- package/src/lib/model/ai-job-activity.d.ts +2 -2
- package/src/lib/model/ai-job-activity.js +6 -4
- package/src/lib/model/ai-job-activity.js.map +1 -1
- package/src/lib/model/ai-job-input.d.ts +33 -0
- package/src/lib/model/ai-job-input.js +59 -0
- package/src/lib/model/ai-job-input.js.map +1 -0
- package/src/lib/model/ai-job-notice.d.ts +4 -0
- package/src/lib/model/ai-job-notice.js +28 -4
- package/src/lib/model/ai-job-notice.js.map +1 -1
- package/src/lib/model/ai-jobs.types.d.ts +69 -3
- package/src/lib/model/ai-jobs.types.js +2 -1
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-site-details.d.ts +122 -0
- package/src/lib/model/ai-site-details.js +300 -0
- package/src/lib/model/ai-site-details.js.map +1 -0
- package/src/lib/model/ai-site-entity.d.ts +181 -0
- package/src/lib/model/ai-site-entity.js +390 -0
- package/src/lib/model/ai-site-entity.js.map +1 -0
- package/src/lib/model/ai-site-import-view.d.ts +130 -0
- package/src/lib/model/ai-site-import-view.js +182 -0
- package/src/lib/model/ai-site-import-view.js.map +1 -0
- package/src/lib/model/ai-site-import.d.ts +302 -0
- package/src/lib/model/ai-site-import.js +526 -0
- package/src/lib/model/ai-site-import.js.map +1 -0
- package/src/lib/model/ai-site-inspiration.d.ts +72 -0
- package/src/lib/model/ai-site-inspiration.js +109 -0
- package/src/lib/model/ai-site-inspiration.js.map +1 -0
- package/src/lib/model/ai-site-job.d.ts +13 -1
- package/src/lib/model/ai-site-job.js +15 -2
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-kinds.js +109 -21
- package/src/lib/model/ai-site-kinds.js.map +1 -1
- package/src/lib/model/ai-site-look.d.ts +10 -0
- package/src/lib/model/ai-site-look.js +24 -0
- package/src/lib/model/ai-site-look.js.map +1 -1
- package/src/lib/model/ai-site-start-seo.d.ts +19 -5
- package/src/lib/model/ai-site-start-seo.js +25 -7
- package/src/lib/model/ai-site-start-seo.js.map +1 -1
- package/src/lib/model/ai-site-start-steps.d.ts +9 -3
- package/src/lib/model/ai-site-start-steps.js +67 -5
- package/src/lib/model/ai-site-start-steps.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +81 -4
- package/src/lib/model/ai-site-start.js +207 -13
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/model/ai-upsell-copy.js +4 -1
- package/src/lib/model/ai-upsell-copy.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +4 -3
- package/src/lib/plan-entitlements.js +3 -8
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/runtime/ai-dataset-generation.d.ts +8 -1
- package/src/lib/runtime/ai-dataset-generation.js +12 -2
- package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +26 -33
- package/src/lib/runtime/ai-doctrine-validators.js +54 -44
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.js +1 -1
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-node-tree.d.ts +7 -0
- package/src/lib/runtime/ai-node-tree.js +9 -1
- package/src/lib/runtime/ai-node-tree.js.map +1 -1
- package/src/lib/server/ai-create-route.d.ts +1 -1
- package/src/lib/server/ai-create-route.js +3 -4
- package/src/lib/server/ai-create-route.js.map +1 -1
- package/src/lib/server/ai-jobs-resume.js +27 -3
- package/src/lib/server/ai-jobs-resume.js.map +1 -1
- package/src/lib/server/ai-jobs-route.js +18 -0
- package/src/lib/server/ai-jobs-route.js.map +1 -1
- package/src/lib/server/ai-site-import-route.d.ts +89 -0
- package/src/lib/server/ai-site-import-route.js +356 -0
- package/src/lib/server/ai-site-import-route.js.map +1 -0
- package/src/lib/server.js +14 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/site-import/fixtures/site-import-crawl-recordings.d.ts +45 -0
- package/src/lib/site-import/fixtures/site-import-crawl-recordings.js +313 -0
- package/src/lib/site-import/fixtures/site-import-crawl-recordings.js.map +1 -0
- package/src/lib/site-import/fixtures/site-import-wave-recordings.d.ts +50 -0
- package/src/lib/site-import/fixtures/site-import-wave-recordings.js +217 -0
- package/src/lib/site-import/fixtures/site-import-wave-recordings.js.map +1 -0
- package/src/lib/site-import/site-import-crawl.d.ts +163 -0
- package/src/lib/site-import/site-import-crawl.js +467 -0
- package/src/lib/site-import/site-import-crawl.js.map +1 -0
- package/src/lib/site-import/site-import-flag.d.ts +23 -0
- package/src/lib/site-import/site-import-flag.js +23 -0
- package/src/lib/site-import/site-import-flag.js.map +1 -0
- package/src/lib/site-import/site-import-html.d.ts +94 -0
- package/src/lib/site-import/site-import-html.js +462 -0
- package/src/lib/site-import/site-import-html.js.map +1 -0
- package/src/lib/site-import/site-import-images.d.ts +54 -0
- package/src/lib/site-import/site-import-images.js +204 -0
- package/src/lib/site-import/site-import-images.js.map +1 -0
- package/src/lib/site-import/site-import-page.d.ts +48 -0
- package/src/lib/site-import/site-import-page.js +150 -0
- package/src/lib/site-import/site-import-page.js.map +1 -0
- package/src/lib/site-import/site-import-review.d.ts +40 -0
- package/src/lib/site-import/site-import-review.js +149 -0
- package/src/lib/site-import/site-import-review.js.map +1 -0
- package/src/lib/site-import/site-import-robots.d.ts +52 -0
- package/src/lib/site-import/site-import-robots.js +126 -0
- package/src/lib/site-import/site-import-robots.js.map +1 -0
- package/src/lib/usage/assist-credits.d.ts +4 -4
- package/src/lib/usage/assist-credits.js +4 -4
- package/src/lib/usage/assist-credits.js.map +1 -1
- package/src/lib/usage/staff-org-ai-view.js +6 -3
- package/src/lib/usage/staff-org-ai-view.js.map +1 -1
- package/src/lib/usage/staff-org-ai.d.ts +4 -2
- package/src/lib/usage/staff-org-ai.js +7 -7
- package/src/lib/usage/staff-org-ai.js.map +1 -1
- package/src/lib/usage/staff-org-usage-ai.js +2 -2
- package/src/lib/usage/staff-org-usage-ai.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 { aiLayoutSpeaksOtherThanEnglish } from '../layout-language/ai-layout-labels'\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)(?![\\p{L}\\p{M}\\p{N}])|[,;:(—–]|\\s-\\s/iu\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)(?![\\p{L}\\p{M}\\p{N}])|[;(—–]|\\s-\\s/giu\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, 'iu')\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 (!/^\\p{Lu}[\\p{Ll}\\p{M}'’]*$/u.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 || /^[\\p{Ll}\\p{N}]/u.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)(?![\\p{L}\\p{M}\\p{N}])/iu\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 // Read unaccented, so \"éclair\" takes \"an\" as \"eclair\" would.\n return `${/^(?:[aeio]|u(?!ni|s[eu]))/i.test(phrase.normalize('NFD')) ? '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 // The article's end rather than `\\s+`, so an answer that is ONLY an article\n // leaves nothing behind and the caller proposes nothing: a one-letter title\n // is a worse thing to stage into a required field than no proposal at all.\n // A letter in any script continues the word (AGL-3791), so \"Año Nuevo\" keeps\n // its \"A\", as `\\b`, which reads \"ñ\" as no letter, did not.\n const text = about.replace(/\\s+/g, ' ').trim().replace(/^(?:an?|the)(?![\\p{L}\\p{M}\\p{N}])\\s*/iu, '')\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 /** The language the site's pages are written in, a code such as `es`; absent, English. */\n language?: string | null\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 *\n * In a language other than English the listing is the person's own words\n * with no English set around them (AGL-3792): the title adds no \"in\" before\n * the city, and the description is what the site is, as the sentence they\n * wrote it in. Composed as English, a Spanish bakery's listing read\n * \"Panadería Flor de Oaxaca is an una panadería familiar de San Antonio…\"\n * (job 4v-nRPeKDH).\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 english = !aiLayoutSpeaksOtherThanEnglish(input.language)\n const title = aiSiteSeoTitle({ name, subject, city: english ? city : '' })\n const description = english\n ? aiSiteSeoDescription({ name, subject, about, audience, ...(city ? { city } : {}) })\n : clip(sentence(subject.replace(/[.!?]+$/, '')), AI_SITE_SEO_LIMITS.description)\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, in the language its plan writes the site in, for the step that reports it. */\nexport function aiSiteSeoProposalForInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n language?: string | null,\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 ...(language ? { language } : {}),\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","aiLayoutSpeaksOtherThanEnglish","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","normalize","toUpperCase","trimmed","aiSiteSeoProposal","siteName","english","language","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 { aiLayoutSpeaksOtherThanEnglish } from '../layout-language/ai-layout-labels'\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 * ── Written over the seeded default, else a proposal ─────────────────────\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.\n *\n * The one write is the publish's (AGL-3979): a site whose listing is still\n * the one its creation seeded — its name and \"Welcome to …\" — takes these\n * values when the start goes live (`aiSiteListingUpdates`), since nobody\n * chose that default. A listing the owner changed is never written over; it\n * is offered here as before.\n *\n * Both name the site where the start gave no business name of its own: the\n * site's name leads the title and the description, so the listing that\n * replaces \"Hillside Dog Grooming\" as the site's title still says it.\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. Never at \"and\", which joins the parts of one subject\n * (AGL-3988): the beta.270 Sugar Hollow listing was cut to \"Sugar Hollow\n * Photography — Wedding\" from \"a wedding and family photographer in the\n * Blue Ridge Mountains who books sessions and elopements\".\n */\nconst CLAUSE_BOUNDARY =\n /\\s+(?:that|which|who|whose|where|offering|providing|specializing|serving|helping|so|because|with|for|to|by|but)(?![\\p{L}\\p{M}\\p{N}])|[,;:(—–]|\\s-\\s/iu\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)(?![\\p{L}\\p{M}\\p{N}])|[;(—–]|\\s-\\s/giu\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, 'iu')\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 (!/^\\p{Lu}[\\p{Ll}\\p{M}'’]*$/u.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 || /^[\\p{Ll}\\p{N}]/u.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)(?![\\p{L}\\p{M}\\p{N}])/iu\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 // Read unaccented, so \"éclair\" takes \"an\" as \"eclair\" would.\n return `${/^(?:[aeio]|u(?!ni|s[eu]))/i.test(phrase.normalize('NFD')) ? '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 // The article's end rather than `\\s+`, so an answer that is ONLY an article\n // leaves nothing behind and the caller proposes nothing: a one-letter title\n // is a worse thing to stage into a required field than no proposal at all.\n // A letter in any script continues the word (AGL-3791), so \"Año Nuevo\" keeps\n // its \"A\", as `\\b`, which reads \"ñ\" as no letter, did not.\n const text = about.replace(/\\s+/g, ' ').trim().replace(/^(?:an?|the)(?![\\p{L}\\p{M}\\p{N}])\\s*/iu, '')\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 /** The language the site's pages are written in, a code such as `es`; absent, English. */\n language?: string | null\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 *\n * In a language other than English the listing is the person's own words\n * with no English set around them (AGL-3792): the title adds no \"in\" before\n * the city, and the description is what the site is, as the sentence they\n * wrote it in. Composed as English, a Spanish bakery's listing read\n * \"Panadería Flor de Oaxaca is an una panadería familiar de San Antonio…\"\n * (job 4v-nRPeKDH).\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 english = !aiLayoutSpeaksOtherThanEnglish(input.language)\n const title = aiSiteSeoTitle({ name, subject, city: english ? city : '' })\n const description = english\n ? aiSiteSeoDescription({ name, subject, about, audience, ...(city ? { city } : {}) })\n : clip(sentence(subject.replace(/[.!?]+$/, '')), AI_SITE_SEO_LIMITS.description)\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/**\n * The proposal a job's inputs imply, in the language its plan writes the site\n * in, for the step that reports it. `siteName` is the site's own name, which\n * leads the listing where the inputs name no business (AGL-3979): a guided\n * start names the site on its first step, not in its inputs.\n */\nexport function aiSiteSeoProposalForInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n language?: string | null,\n siteName?: string | null,\n): AiSiteSeoProposal | null {\n const words = aiSiteWords(inputs)\n const given = inputs?.['businessName']\n const businessName = typeof given === 'string' && given.trim() ? given : (siteName ?? '').trim() || given\n const city = inputs?.['city']\n return aiSiteSeoProposal({\n ...words,\n ...(typeof businessName === 'string' ? { siteName: businessName } : {}),\n ...(typeof city === 'string' ? { city } : {}),\n ...(language ? { language } : {}),\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","aiLayoutSpeaksOtherThanEnglish","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","normalize","toUpperCase","trimmed","aiSiteSeoProposal","siteName","english","language","kind","values","notes","aiSiteSeoProposalForInputs","inputs","given","businessName","isRecord","Array","isArray","aiSiteSeoProposalOf","outputs","output","resource","proposal","note"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,2CAA0C;AACnF,SAASC,8BAA8B,QAAQ,yCAAqC;AAEpF,SAASC,WAAW,QAA0B,mBAAe;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED;;;;;;;;CAQC,GACD,OAAO,MAAMC,0BAA0B;IACrCC,OAAO;IACPC,aAAa;AACf,EAAU;AAKV;;;CAGC,GACD,OAAO,MAAMC,qBAAqBN,yBAAwB;AAE1D,+EAA+E,GAC/E,OAAO,MAAMO,4BAA4B,eAAc;AAEvD,4DAA4D,GAC5D,OAAO,MAAMC,wBAAwB,eAAc;AAUnD,8DAA8D,GAC9D,OAAO,MAAMC,mBACX,6GAA4G;AAE9G;;;CAGC,GACD,MAAMC,WAAW,IAAIC,IAAI;IACvB;IAAK;IAAM;IAAO;IAAM;IAAM;IAAM;IAAO;IAAQ;IAAM;IAAQ;IAAM;IAAM;IAAM;IACnF;IAAQ;IAAO;IAAM;IAAS;IAAO;IAAQ;IAAS;IAAS;IAAS;IAAQ;IAAO;IAAK;IAAK;IAAK;CACvG;AAED,8EAA8E,GAC9E,SAASC,gBAAgBC,IAAY;IACnC,MAAMC,QAAQD,KAAKE,KAAK,CAAC;IACzB,MAAOD,MAAME,MAAM,GAAG,KAAKN,SAASO,GAAG,CAACH,KAAK,CAACA,MAAME,MAAM,GAAG,EAAE,CAACE,WAAW,IAAKJ,MAAMK,GAAG;IACzF,OAAOL,MAAMM,IAAI,CAAC,KAAKC,OAAO,CAAC,gBAAgB,IAAIC,IAAI;AACzD;AAEA,wFAAwF,GACxF,SAASC,KAAKC,KAAa,EAAEC,GAAW;IACtC,MAAMZ,OAAOW,MAAMH,OAAO,CAAC,QAAQ,KAAKC,IAAI;IAC5C,IAAIT,KAAKG,MAAM,IAAIS,KAAK,OAAOZ;IAC/B,MAAMa,MAAMb,KAAKc,KAAK,CAAC,GAAGF,MAAM;IAChC,MAAMG,QAAQF,IAAIG,WAAW,CAAC;IAC9B,OAAOjB,gBAAgB,AAACgB,CAAAA,QAAQH,MAAM,IAAIC,IAAIC,KAAK,CAAC,GAAGC,SAASf,KAAKc,KAAK,CAAC,GAAGF,IAAG,EAAGH,IAAI;AAC1F;AAEA;;;;;;;CAOC,GACD,MAAMQ,kBACJ;AAEF;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,eAAeC,KAAwD;QAEvEA,aAEAA;IAHd,MAAMP,MAAMnB,mBAAmBF,KAAK;IACpC,MAAM6B,OAAO,EAACD,cAAAA,MAAMC,IAAI,YAAVD,cAAc,IAAIX,OAAO,CAAC,QAAQ,KAAKC,IAAI;IACzD,MAAMY,UAAUtB,gBAAgBoB,MAAME,OAAO,CAACb,OAAO,CAAC,QAAQ,KAAKC,IAAI;IACvE,MAAMa,OAAO,EAACH,cAAAA,MAAMG,IAAI,YAAVH,cAAc,IAAIX,OAAO,CAAC,QAAQ,KAAKC,IAAI;IACzD,MAAMc,SAAS,CAACC,OACdF,QAAQ,CAACE,KAAKnB,WAAW,GAAGoB,QAAQ,CAACH,KAAKjB,WAAW,MAAM,GAAGmB,KAAK,IAAI,EAAEF,MAAM,GAAGE;IACpF,MAAME,WAAWL,QAAQM,MAAM,CAACV;IAChC,MAAMW,SAAS7B,gBAAgB2B,WAAW,IAAIL,QAAQP,KAAK,CAAC,GAAGY,YAAYL;IAC3E,MAAMQ,OAAO9B,gBAAgB6B,OAAOpB,OAAO,CAAC,kBAAkB,QAAQoB;IACtE,MAAME,QAAQ;WAAI,IAAIhC,IAAI;YAACyB,OAAOF;YAAUA;YAASE,OAAOK;YAASA;YAAQC;SAAK;KAAE;IACpF,MAAME,aAAaX,OAAO;WAAIU,MAAME,GAAG,CAAC,CAACR,OAAS,GAAGJ,KAAK,GAAG,EAAEI,MAAM;QAAGJ;KAAK,GAAGU;IAChF,MAAMG,OAAOF,WAAWG,IAAI,CAAC,CAACC,YAAcA,UAAUhC,MAAM,IAAIS;IAChE,OAAOqB,eAAAA,OAAQvB,KAAKqB,UAAU,CAACA,WAAW5B,MAAM,GAAG,EAAE,EAAES;AACzD;AAEA;;;;;CAKC,GACD,MAAMwB,kBAAkB;AAExB,yGAAyG,GACzG,SAASC,YAAYrC,IAAY;IAC/B,MAAMsC,QAAQ;QAACtC;KAAK;IACpB,MAAMuC,OAAO;WAAIvC,KAAKwC,QAAQ,CAACJ;KAAiB,CAACJ,GAAG,CAAC,CAACS;YAAUA;gBAAAA,eAAAA,MAAMC,KAAK,YAAXD,eAAe,CAAC;OAAGE,MAAM,CAAC,CAACC,KAAOA,KAAK;IACvG,KAAK,MAAMA,MAAML,KAAKM,OAAO,GAAI;QAC/B,MAAMC,OAAO/C,gBAAgBC,KAAKc,KAAK,CAAC,GAAG8B,IAAInC,IAAI;QACnD,IAAIqC,QAAQ,CAACR,MAAMb,QAAQ,CAACqB,OAAOR,MAAMS,IAAI,CAACD;IAChD;IACA,OAAOR;AACT;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASU,qBAAqB7B,KAOpC;IACC,MAAMP,MAAMnB,mBAAmBD,WAAW;IAC1C,MAAMyD,OAAO9B,MAAMG,IAAI,GAAG;QAACH,MAAMG,IAAI;KAAC,GAAG,EAAE;IAC3C,MAAM4B,OAAO,CAAC1B,OACZL,MAAMC,IAAI,GAAG,GAAGD,MAAMC,IAAI,CAAC,IAAI,EAAE+B,YAAYC,kBAAkB5B,MAAMyB,QAAQ,GAAGI,UAAU7B;IAC5F,MAAM8B,SAASjB,YAAYtC,gBAAgBwD,oBAAoBpC,MAAMqC,KAAK,EAAEhD,OAAO,CAAC,WAAW;IAC/F,MAAMiD,WAAW1D,gBAAgBoB,MAAMsC,QAAQ,CAACjD,OAAO,CAAC,WAAW,IAAIA,OAAO,CAAC,YAAY;IAC3F,MAAMkD,YAAYD,WAAWpB,YAAYoB,UAAUzB,GAAG,CAAC,CAACc,OAASM,kBAAkBN,MAAMG,SAAS,EAAE;IACpG,KAAK,MAAMO,SAASF,OAAQ;QAC1B,MAAMK,QAAQ,gBAAgBC,IAAI,CAACJ,UAAUA,MAAM7B,MAAM,CAACkC,uBAAuB,IAAI,WAAW;QAChG,KAAK,MAAMC,OAAOJ,UAAW;YAC3B,MAAM1D,OAAO+D,SAAS,GAAGb,KAAKM,SAASG,QAAQG,KAAK;YACpD,IAAI9D,KAAKG,MAAM,IAAIS,KAAK,OAAOZ;QACjC;QACA,MAAMA,OAAO+D,SAASb,KAAKM;QAC3B,IAAIxD,KAAKG,MAAM,IAAIS,KAAK,OAAOZ;IACjC;IACA,OAAOU,KAAKqD,SAASb,KAAKI,MAAM,CAACA,OAAOnD,MAAM,GAAG,EAAE,IAAIgB,MAAME,OAAO,IAAIT;AAC1E;AAEA,2EAA2E,GAC3E,MAAMiD,sBAAsB,IAAIG,OAAO5B,gBAAgB6B,MAAM,EAAE;AAE/D;;;;;;CAMC,GACD,OAAO,SAASV,oBAAoBC,KAAa;IAC/C,MAAMxD,OAAOwD,MAAMhD,OAAO,CAAC,QAAQ,KAAKC,IAAI;IAC5C,MAAMyD,QAAQlE,KAAKmE,OAAO,CAAC;IAC3B,MAAMC,OAAOF,QAAQ,IAAInE,gBAAgBC,KAAKc,KAAK,CAAC,GAAGoD,OAAOzD,IAAI,MAAM;IACxE,OAAO2D,QAAQpE;AACjB;AAEA;;;;;;CAMC,GACD,MAAMqE,gBAAgB,IAAIvE,IAAI;IAC5B;IAAK;IAAM;IAAO;IAAO;IAAM;IAAQ;IAAS;IAAQ;IAAO;IAAO;IAAS;IAAQ;IACvF;IAAU;IAAY;IAAU;IAAS;IAAa;IAAc;IAAY;IAAW;IAC3F;IAAY;IAAU;IAAS;IAAY;IAAW;IAAW;IAAS;IAAO;IAAU;IAC3F;IAAc;IAAU;IAAW;IAAW;IAAa;IAAW;IAAc;IACpF;IAAS;IAAS;IAAQ;IAAO;IAAS;IAAe;IAAU;IAAgB;CACpF;AAED;;;;;;;CAOC,GACD,OAAO,SAASsD,kBAAkBkB,MAAc,EAAErB,OAA0B,EAAE;IAC5E,MAAMjD,OAAOsE,OAAO9D,OAAO,CAAC,QAAQ,KAAKC,IAAI;IAC7C,MAAM,CAAC8D,QAAQ,EAAE,EAAEC,SAAS,EAAE,CAAC,GAAGxE,KAAKE,KAAK,CAAC;IAC7C,IAAI,CAAC,mmgBAA4B0D,IAAI,CAACW,QAAQ,OAAOvE;IACrD,IAAIiD,KAAKwB,IAAI,CAAC,CAACC,OAASA,QAAQ1E,KAAKK,WAAW,GAAGsE,UAAU,CAACD,KAAKrE,WAAW,MAAM,OAAOL;IAC3F,MAAM4E,SAASP,cAAcjE,GAAG,CAACmE,MAAMlE,WAAW;IAClD,MAAMwE,eAAe,CAACL,UAAU,ivQAAkBZ,IAAI,CAACY;IACvD,IAAI,CAACI,UAAU,CAACC,cAAc,OAAO7E;IACrC,OAAOuE,MAAMlE,WAAW,KAAKL,KAAKc,KAAK,CAACyD,MAAMpE,MAAM;AACtD;AAEA,+DAA+D,GAC/D,MAAM2E,cAAc;AAEpB;;;;;CAKC,GACD,SAAS3B,YAAYmB,MAAc;QAGpB;IAFb,IAAI,CAACA,UAAUQ,YAAYlB,IAAI,CAACU,SAAS,OAAOA;IAChD,MAAM5C,WAAW4C,OAAO3C,MAAM,CAACkC;IAC/B,MAAMO,QAAO,oBAAA,AAAC1C,CAAAA,WAAW,IAAI4C,OAAOxD,KAAK,CAAC,GAAGY,YAAY4C,MAAK,EAAGpE,KAAK,CAAC,UAAUyC,MAAM,CAACoC,SAASzE,GAAG,cAAvF,oBAA6F;IAC1G,IAAI,WAAWsD,IAAI,CAACQ,SAAS,CAAC,QAAQR,IAAI,CAACQ,OAAO,OAAOE;IACzD,6DAA6D;IAC7D,OAAO,GAAG,6BAA6BV,IAAI,CAACU,OAAOU,SAAS,CAAC,UAAU,OAAO,IAAI,CAAC,EAAEV,QAAQ;AAC/F;AAEA;;;;;CAKC,GACD,SAASjB,UAAUG,KAAa;IAC9B,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,2DAA2D;IAC3D,MAAMxD,OAAOwD,MAAMhD,OAAO,CAAC,QAAQ,KAAKC,IAAI,GAAGD,OAAO,CAAC,wikBAA0C;IACjG,IAAI,CAACR,MAAM,OAAO;IAClB,OAAOA,IAAI,CAAC,EAAE,CAACiF,WAAW,KAAKjF,KAAKc,KAAK,CAAC;AAC5C;AAEA,sDAAsD,GACtD,SAASiD,SAAS/D,IAAY;IAC5B,MAAMkF,UAAUlF,KAAKS,IAAI;IACzB,OAAO,SAASmD,IAAI,CAACsB,WAAWA,UAAU,GAAGA,QAAQ,CAAC,CAAC;AACzD;AAeA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBhE,KAAqB;QAEnBA,cAGpBA,iBACIA,iBACJA;IANd,2FAA2F;IAC3F,MAAMqC,QAAQD,qBAAoBpC,eAAAA,MAAMqC,KAAK,YAAXrC,eAAe;IACjD,MAAME,UAAUgC,UAAUG;IAC1B,IAAI,CAACnC,SAAS,OAAO;IACrB,MAAMD,OAAO,EAACD,kBAAAA,MAAMiE,QAAQ,YAAdjE,kBAAkB,IAAIX,OAAO,CAAC,QAAQ,KAAKC,IAAI;IAC7D,MAAMgD,WAAW,EAACtC,kBAAAA,MAAMsC,QAAQ,YAAdtC,kBAAkB,IAAIX,OAAO,CAAC,QAAQ,KAAKC,IAAI;IACjE,MAAMa,OAAO,EAACH,cAAAA,MAAMG,IAAI,YAAVH,cAAc,IAAIX,OAAO,CAAC,QAAQ,KAAKC,IAAI;IACzD,MAAM4E,UAAU,CAACjG,+BAA+B+B,MAAMmE,QAAQ;IAC9D,MAAM/F,QAAQ2B,eAAe;QAAEE;QAAMC;QAASC,MAAM+D,UAAU/D,OAAO;IAAG;IACxE,MAAM9B,cAAc6F,UAChBrC,qBAAqB;QAAE5B;QAAMC;QAASmC;QAAOC;OAAcnC,OAAO;QAAEA;IAAK,IAAI,CAAC,MAC9EZ,KAAKqD,SAAS1C,QAAQb,OAAO,CAAC,WAAW,MAAMf,mBAAmBD,WAAW;IACjF,OAAO;QACL+F,MAAM7F;QACN8F,QAAQ;YACN,CAAClG,wBAAwBC,KAAK,CAAC,EAAEA;YACjC,CAACD,wBAAwBE,WAAW,CAAC,EAAEA;QACzC;QACAiG,OAAO;YAAC7F;SAAiB;IAC3B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAAS8F,2BACdC,MAA4D,EAC5DL,QAAwB,EACxBF,QAAwB;IAExB,MAAMnF,QAAQZ,YAAYsG;IAC1B,MAAMC,QAAQD,0BAAAA,MAAQ,CAAC,eAAe;IACtC,MAAME,eAAe,OAAOD,UAAU,YAAYA,MAAMnF,IAAI,KAAKmF,QAAQ,CAACR,mBAAAA,WAAY,IAAI3E,IAAI,MAAMmF;IACpG,MAAMtE,OAAOqE,0BAAAA,MAAQ,CAAC,OAAO;IAC7B,OAAOR,kBAAkB,aACpBlF,OACC,OAAO4F,iBAAiB,WAAW;QAAET,UAAUS;IAAa,IAAI,CAAC,GACjE,OAAOvE,SAAS,WAAW;QAAEA;IAAK,IAAI,CAAC,GACvCgE,WAAW;QAAEA;IAAS,IAAI,CAAC;AAEnC;AAEA,4DAA4D,GAC5D,SAASQ,SAASnF,KAAc;IAC9B,OAAO,OAAOA,UAAU,YAAYA,UAAU,QAAQ,CAACoF,MAAMC,OAAO,CAACrF;AACvE;AAEA;;;;;;CAMC,GACD,OAAO,SAASsF,oBACdC,OAAiF;IAEjF,KAAK,MAAMC,UAAUD,kBAAAA,UAAW,EAAE,CAAE;QAClC,IAAIC,OAAOC,QAAQ,KAAK,OAAO;QAC/B,MAAMC,WAAWF,OAAOE,QAAQ;QAChC,IAAI,CAACP,SAASO,aAAaA,QAAQ,CAAC,OAAO,KAAK3G,2BAA2B;QAC3E,MAAM8F,SAASM,SAASO,QAAQ,CAAC,SAAS,IAAIA,QAAQ,CAAC,SAAS,GAAG,CAAC;QACpE,MAAM9G,QAAQiG,MAAM,CAAClG,wBAAwBC,KAAK,CAAC;QACnD,MAAMC,cAAcgG,MAAM,CAAClG,wBAAwBE,WAAW,CAAC;QAC/D,IAAI,OAAOD,UAAU,YAAY,OAAOC,gBAAgB,UAAU;QAClE,IAAI,CAACD,MAAMkB,IAAI,MAAM,CAACjB,YAAYiB,IAAI,IAAI;QAC1C,MAAMgF,QAAQM,MAAMC,OAAO,CAACK,QAAQ,CAAC,QAAQ,IACzCA,QAAQ,CAAC,QAAQ,CAAC1D,MAAM,CAAC,CAAC2D,OAAyB,OAAOA,SAAS,YACnE,EAAE;QACN,OAAO;YACLf,MAAM7F;YACN8F,QAAQ;gBACN,CAAClG,wBAAwBC,KAAK,CAAC,EAAEA;gBACjC,CAACD,wBAAwBE,WAAW,CAAC,EAAEA;YACzC;YACAiG;QACF;IACF;IACA,OAAO;AACT"}
|
|
@@ -14,7 +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
|
|
17
|
+
import { type AiSiteStartAnswers, type AiSiteStartExtraKey } from './ai-site-start';
|
|
18
18
|
/**
|
|
19
19
|
* The guided start's steps (AGL-3898): the same questions `ai-site-start.ts`
|
|
20
20
|
* asks, one decision to a step, so a person answers one thing at a time and
|
|
@@ -35,8 +35,14 @@ export type AiSiteStartStep = (typeof AI_SITE_START_STEPS)[number];
|
|
|
35
35
|
/** Each step's short name, as the progress indicator shows it. */
|
|
36
36
|
export declare const AI_SITE_START_STEP_LABELS: Readonly<Record<AiSiteStartStep, string>>;
|
|
37
37
|
/** An answer a step asks for, by its key on {@link AiSiteStartAnswers}. */
|
|
38
|
-
export type AiSiteStartField = 'siteType' | 'audience' | 'kind' | 'pages' | 'submissions' | 'welcomeEmail';
|
|
39
|
-
/**
|
|
38
|
+
export type AiSiteStartField = 'siteType' | 'audience' | 'kind' | 'inspiration' | 'pages' | 'submissions' | 'welcomeEmail' | AiSiteStartExtraKey;
|
|
39
|
+
/**
|
|
40
|
+
* The extras each step's "More details" offers (AGL-3979), in the order it
|
|
41
|
+
* draws them. The phone number and the email are on the Details step itself,
|
|
42
|
+
* above where messages go: they are the two a new site most needs.
|
|
43
|
+
*/
|
|
44
|
+
export declare const AI_SITE_START_MORE_FIELDS: Readonly<Record<AiSiteStartStep, readonly AiSiteStartExtraKey[]>>;
|
|
45
|
+
/** The answers each step holds, its "More details" last. */
|
|
40
46
|
export declare const AI_SITE_START_STEP_FIELDS: Readonly<Record<AiSiteStartStep, readonly AiSiteStartField[]>>;
|
|
41
47
|
/** Why an answer cannot go forward, as a closed code the funnel can count. */
|
|
42
48
|
export type AiSiteStartErrorCode = 'required' | 'too_long' | 'out_of_range' | 'invalid';
|
|
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { AI_SITE_INPUT_MAX_CHARS, AI_SITE_SUBMISSIONS, aiSitePagesBand } from "./ai-site-job.js";
|
|
18
|
+
import { aiSiteStartExtraProblem, aiSiteStartInspirationRefusal } from "./ai-site-start.js";
|
|
18
19
|
/**
|
|
19
20
|
* The guided start's steps (AGL-3898): the same questions `ai-site-start.ts`
|
|
20
21
|
* asks, one decision to a step, so a person answers one thing at a time and
|
|
@@ -44,21 +45,58 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
44
45
|
details: 'Details',
|
|
45
46
|
review: 'Review'
|
|
46
47
|
};
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The extras each step's "More details" offers (AGL-3979), in the order it
|
|
50
|
+
* draws them. The phone number and the email are on the Details step itself,
|
|
51
|
+
* above where messages go: they are the two a new site most needs.
|
|
52
|
+
*/ export const AI_SITE_START_MORE_FIELDS = {
|
|
48
53
|
choose: [],
|
|
49
54
|
kind: [
|
|
50
|
-
'
|
|
55
|
+
'tagline',
|
|
56
|
+
'founded'
|
|
51
57
|
],
|
|
52
58
|
audience: [
|
|
53
|
-
'
|
|
59
|
+
'serviceArea',
|
|
60
|
+
'priceRange',
|
|
61
|
+
'languages'
|
|
54
62
|
],
|
|
55
63
|
style: [
|
|
56
|
-
'
|
|
64
|
+
'brand'
|
|
57
65
|
],
|
|
58
66
|
details: [
|
|
67
|
+
'street',
|
|
68
|
+
'city',
|
|
69
|
+
'region',
|
|
70
|
+
'postalCode',
|
|
71
|
+
'country',
|
|
72
|
+
'hours',
|
|
73
|
+
'social'
|
|
74
|
+
],
|
|
75
|
+
review: []
|
|
76
|
+
};
|
|
77
|
+
/** The answers each step holds, its "More details" last. */ export const AI_SITE_START_STEP_FIELDS = {
|
|
78
|
+
choose: [],
|
|
79
|
+
kind: [
|
|
80
|
+
'siteType',
|
|
81
|
+
...AI_SITE_START_MORE_FIELDS.kind
|
|
82
|
+
],
|
|
83
|
+
audience: [
|
|
84
|
+
'audience',
|
|
85
|
+
...AI_SITE_START_MORE_FIELDS.audience
|
|
86
|
+
],
|
|
87
|
+
// The sites they like sit under the style cards (AGL-3975), when asked.
|
|
88
|
+
style: [
|
|
89
|
+
'kind',
|
|
90
|
+
'inspiration',
|
|
91
|
+
...AI_SITE_START_MORE_FIELDS.style
|
|
92
|
+
],
|
|
93
|
+
details: [
|
|
94
|
+
'phone',
|
|
95
|
+
'email',
|
|
59
96
|
'submissions',
|
|
60
97
|
'pages',
|
|
61
|
-
'welcomeEmail'
|
|
98
|
+
'welcomeEmail',
|
|
99
|
+
...AI_SITE_START_MORE_FIELDS.details
|
|
62
100
|
],
|
|
63
101
|
review: []
|
|
64
102
|
};
|
|
@@ -106,8 +144,32 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
106
144
|
code: 'invalid'
|
|
107
145
|
};
|
|
108
146
|
}
|
|
147
|
+
const inspiration = aiSiteStartInspirationRefusal(answers, options);
|
|
148
|
+
if (inspiration) errors.inspiration = {
|
|
149
|
+
message: inspiration,
|
|
150
|
+
code: 'invalid'
|
|
151
|
+
};
|
|
152
|
+
// Every extra is optional; one given is held to its own rule (AGL-3979).
|
|
153
|
+
for (const field of Object.values(AI_SITE_START_STEP_FIELDS).flat()){
|
|
154
|
+
var _answers_field;
|
|
155
|
+
if (field in errors) continue;
|
|
156
|
+
const problem = isExtra(field) ? aiSiteStartExtraProblem(field, (_answers_field = answers[field]) != null ? _answers_field : '') : null;
|
|
157
|
+
if (problem) errors[field] = problem;
|
|
158
|
+
}
|
|
109
159
|
return errors;
|
|
110
160
|
}
|
|
161
|
+
const CORE_FIELDS = new Set([
|
|
162
|
+
'siteType',
|
|
163
|
+
'audience',
|
|
164
|
+
'kind',
|
|
165
|
+
'inspiration',
|
|
166
|
+
'pages',
|
|
167
|
+
'submissions',
|
|
168
|
+
'welcomeEmail'
|
|
169
|
+
]);
|
|
170
|
+
function isExtra(field) {
|
|
171
|
+
return !CORE_FIELDS.has(field);
|
|
172
|
+
}
|
|
111
173
|
/** One step's reasons not to go forward, in the order its fields are drawn; empty when it may. */ export function aiSiteStartStepErrors(step, answers, options = {}) {
|
|
112
174
|
const errors = aiSiteStartFieldErrors(answers, options);
|
|
113
175
|
return AI_SITE_START_STEP_FIELDS[step].flatMap((field)=>{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start-steps.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 { AI_SITE_INPUT_MAX_CHARS, AI_SITE_SUBMISSIONS, aiSitePagesBand } from './ai-site-job'\nimport type { AiSiteStartAnswers } from './ai-site-start'\n\n/**\n * The guided start's steps (AGL-3898): the same questions `ai-site-start.ts`\n * asks, one decision to a step, so a person answers one thing at a time and\n * the funnel can say which one they stopped at.\n *\n * Splitting the questions changes nothing they become. Every step writes the\n * same {@link AiSiteStartAnswers} the single form did, and only the review's\n * \"Plan my site\" turns them into the job's brief and inputs, through the same\n * `aiSiteStartBrief` / `aiSiteStartInputs`. Which answers are required is\n * unchanged too: only what kind of site it is.\n *\n * Pure: ids, titles, which answers each step holds, and each step's reasons\n * not to go forward, worded as `aiSiteStartRefusal` words them.\n */\n\n/** A step of the guided start, in order. `choose` is the starter-or-AI choice the questions follow. */\nexport const AI_SITE_START_STEPS = ['choose', 'kind', 'audience', 'style', 'details', 'review'] as const\n\nexport type AiSiteStartStep = (typeof AI_SITE_START_STEPS)[number]\n\n/** Each step's short name, as the progress indicator shows it. */\nexport const AI_SITE_START_STEP_LABELS: Readonly<Record<AiSiteStartStep, string>> = {\n choose: 'Start',\n kind: 'Your site',\n audience: 'Audience',\n style: 'Style',\n details: 'Details',\n review: 'Review',\n}\n\n/** An answer a step asks for, by its key on {@link AiSiteStartAnswers}. */\nexport type AiSiteStartField = 'siteType' | 'audience' | 'kind' | 'pages' | 'submissions' | 'welcomeEmail'\n\n/** The answers each step holds. */\nexport const AI_SITE_START_STEP_FIELDS: Readonly<Record<AiSiteStartStep, readonly AiSiteStartField[]>> = {\n choose: [],\n kind: ['siteType'],\n audience: ['audience'],\n style: ['kind'],\n details: ['submissions', 'pages', 'welcomeEmail'],\n review: [],\n}\n\n/** Why an answer cannot go forward, as a closed code the funnel can count. */\nexport type AiSiteStartErrorCode = 'required' | 'too_long' | 'out_of_range' | 'invalid'\n\n/** One answer's reason not to go forward: the sentence beside the field, and its code. */\nexport interface AiSiteStartFieldError {\n message: string\n code: AiSiteStartErrorCode\n}\n\n/** The step's place, from 0. */\nexport function aiSiteStartStepIndex(step: AiSiteStartStep): number {\n return AI_SITE_START_STEPS.indexOf(step)\n}\n\n/** The step after this one, or `null` after the review. */\nexport function aiSiteStartNextStep(step: AiSiteStartStep): AiSiteStartStep | null {\n return AI_SITE_START_STEPS[aiSiteStartStepIndex(step) + 1] ?? null\n}\n\n/** The step before this one, or `null` before the choice. */\nexport function aiSiteStartPreviousStep(step: AiSiteStartStep): AiSiteStartStep | null {\n const index = aiSiteStartStepIndex(step)\n return index > 0 ? (AI_SITE_START_STEPS[index - 1] ?? null) : null\n}\n\n/** Every answer's reason not to go forward, by field, with nothing for an answer that may. */\nexport function aiSiteStartFieldErrors(\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): Partial<Record<AiSiteStartField, AiSiteStartFieldError>> {\n const errors: Partial<Record<AiSiteStartField, AiSiteStartFieldError>> = {}\n const siteType = answers.siteType.trim()\n if (!siteType) errors.siteType = { message: 'Say what kind of site this is.', code: 'required' }\n else if (siteType.length > AI_SITE_INPUT_MAX_CHARS) {\n errors.siteType = { message: `Keep what kind of site this is under ${AI_SITE_INPUT_MAX_CHARS} characters.`, code: 'too_long' }\n }\n if (answers.audience.trim().length > AI_SITE_INPUT_MAX_CHARS) {\n errors.audience = { message: `Keep who the site is for under ${AI_SITE_INPUT_MAX_CHARS} characters.`, code: 'too_long' }\n }\n const band = aiSitePagesBand(options.freeTaste)\n if (!Number.isInteger(answers.pages) || answers.pages < band.min || answers.pages > band.max) {\n errors.pages = { message: `A site is planned with ${band.min} to ${band.max} pages.`, code: 'out_of_range' }\n }\n if (!(AI_SITE_SUBMISSIONS as readonly string[]).includes(answers.submissions)) {\n errors.submissions = { message: 'Say where the contact form’s submissions go.', code: 'invalid' }\n }\n return errors\n}\n\n/** One step's reasons not to go forward, in the order its fields are drawn; empty when it may. */\nexport function aiSiteStartStepErrors(\n step: AiSiteStartStep,\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): Array<{ field: AiSiteStartField } & AiSiteStartFieldError> {\n const errors = aiSiteStartFieldErrors(answers, options)\n return AI_SITE_START_STEP_FIELDS[step].flatMap((field) => {\n const error = errors[field]\n return error ? [{ field, ...error }] : []\n })\n}\n\n/** The first step holding an answer that cannot go forward, for a review that was reached with one; `null` for none. */\nexport function aiSiteStartFirstInvalidStep(\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): AiSiteStartStep | null {\n return AI_SITE_START_STEPS.find((step) => aiSiteStartStepErrors(step, answers, options).length > 0) ?? null\n}\n"],"names":["AI_SITE_INPUT_MAX_CHARS","AI_SITE_SUBMISSIONS","aiSitePagesBand","AI_SITE_START_STEPS","AI_SITE_START_STEP_LABELS","choose","kind","audience","style","details","review","AI_SITE_START_STEP_FIELDS","aiSiteStartStepIndex","step","indexOf","aiSiteStartNextStep","aiSiteStartPreviousStep","index","aiSiteStartFieldErrors","answers","options","errors","siteType","trim","message","code","length","band","freeTaste","Number","isInteger","pages","min","max","includes","submissions","aiSiteStartStepErrors","flatMap","field","error","aiSiteStartFirstInvalidStep","find"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start-steps.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 { AI_SITE_INPUT_MAX_CHARS, AI_SITE_SUBMISSIONS, aiSitePagesBand } from './ai-site-job'\nimport {\n aiSiteStartExtraProblem,\n aiSiteStartInspirationRefusal,\n type AiSiteStartAnswers,\n type AiSiteStartExtraKey,\n} from './ai-site-start'\n\n/**\n * The guided start's steps (AGL-3898): the same questions `ai-site-start.ts`\n * asks, one decision to a step, so a person answers one thing at a time and\n * the funnel can say which one they stopped at.\n *\n * Splitting the questions changes nothing they become. Every step writes the\n * same {@link AiSiteStartAnswers} the single form did, and only the review's\n * \"Plan my site\" turns them into the job's brief and inputs, through the same\n * `aiSiteStartBrief` / `aiSiteStartInputs`. Which answers are required is\n * unchanged too: only what kind of site it is.\n *\n * Pure: ids, titles, which answers each step holds, and each step's reasons\n * not to go forward, worded as `aiSiteStartRefusal` words them.\n */\n\n/** A step of the guided start, in order. `choose` is the starter-or-AI choice the questions follow. */\nexport const AI_SITE_START_STEPS = ['choose', 'kind', 'audience', 'style', 'details', 'review'] as const\n\nexport type AiSiteStartStep = (typeof AI_SITE_START_STEPS)[number]\n\n/** Each step's short name, as the progress indicator shows it. */\nexport const AI_SITE_START_STEP_LABELS: Readonly<Record<AiSiteStartStep, string>> = {\n choose: 'Start',\n kind: 'Your site',\n audience: 'Audience',\n style: 'Style',\n details: 'Details',\n review: 'Review',\n}\n\n/** An answer a step asks for, by its key on {@link AiSiteStartAnswers}. */\nexport type AiSiteStartField =\n | 'siteType'\n | 'audience'\n | 'kind'\n | 'inspiration'\n | 'pages'\n | 'submissions'\n | 'welcomeEmail'\n | AiSiteStartExtraKey\n\n/**\n * The extras each step's \"More details\" offers (AGL-3979), in the order it\n * draws them. The phone number and the email are on the Details step itself,\n * above where messages go: they are the two a new site most needs.\n */\nexport const AI_SITE_START_MORE_FIELDS: Readonly<Record<AiSiteStartStep, readonly AiSiteStartExtraKey[]>> = {\n choose: [],\n kind: ['tagline', 'founded'],\n audience: ['serviceArea', 'priceRange', 'languages'],\n style: ['brand'],\n details: ['street', 'city', 'region', 'postalCode', 'country', 'hours', 'social'],\n review: [],\n}\n\n/** The answers each step holds, its \"More details\" last. */\nexport const AI_SITE_START_STEP_FIELDS: Readonly<Record<AiSiteStartStep, readonly AiSiteStartField[]>> = {\n choose: [],\n kind: ['siteType', ...AI_SITE_START_MORE_FIELDS.kind],\n audience: ['audience', ...AI_SITE_START_MORE_FIELDS.audience],\n // The sites they like sit under the style cards (AGL-3975), when asked.\n style: ['kind', 'inspiration', ...AI_SITE_START_MORE_FIELDS.style],\n details: ['phone', 'email', 'submissions', 'pages', 'welcomeEmail', ...AI_SITE_START_MORE_FIELDS.details],\n review: [],\n}\n\n/** Why an answer cannot go forward, as a closed code the funnel can count. */\nexport type AiSiteStartErrorCode = 'required' | 'too_long' | 'out_of_range' | 'invalid'\n\n/** One answer's reason not to go forward: the sentence beside the field, and its code. */\nexport interface AiSiteStartFieldError {\n message: string\n code: AiSiteStartErrorCode\n}\n\n/** The step's place, from 0. */\nexport function aiSiteStartStepIndex(step: AiSiteStartStep): number {\n return AI_SITE_START_STEPS.indexOf(step)\n}\n\n/** The step after this one, or `null` after the review. */\nexport function aiSiteStartNextStep(step: AiSiteStartStep): AiSiteStartStep | null {\n return AI_SITE_START_STEPS[aiSiteStartStepIndex(step) + 1] ?? null\n}\n\n/** The step before this one, or `null` before the choice. */\nexport function aiSiteStartPreviousStep(step: AiSiteStartStep): AiSiteStartStep | null {\n const index = aiSiteStartStepIndex(step)\n return index > 0 ? (AI_SITE_START_STEPS[index - 1] ?? null) : null\n}\n\n/** Every answer's reason not to go forward, by field, with nothing for an answer that may. */\nexport function aiSiteStartFieldErrors(\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): Partial<Record<AiSiteStartField, AiSiteStartFieldError>> {\n const errors: Partial<Record<AiSiteStartField, AiSiteStartFieldError>> = {}\n const siteType = answers.siteType.trim()\n if (!siteType) errors.siteType = { message: 'Say what kind of site this is.', code: 'required' }\n else if (siteType.length > AI_SITE_INPUT_MAX_CHARS) {\n errors.siteType = { message: `Keep what kind of site this is under ${AI_SITE_INPUT_MAX_CHARS} characters.`, code: 'too_long' }\n }\n if (answers.audience.trim().length > AI_SITE_INPUT_MAX_CHARS) {\n errors.audience = { message: `Keep who the site is for under ${AI_SITE_INPUT_MAX_CHARS} characters.`, code: 'too_long' }\n }\n const band = aiSitePagesBand(options.freeTaste)\n if (!Number.isInteger(answers.pages) || answers.pages < band.min || answers.pages > band.max) {\n errors.pages = { message: `A site is planned with ${band.min} to ${band.max} pages.`, code: 'out_of_range' }\n }\n if (!(AI_SITE_SUBMISSIONS as readonly string[]).includes(answers.submissions)) {\n errors.submissions = { message: 'Say where the contact form’s submissions go.', code: 'invalid' }\n }\n const inspiration = aiSiteStartInspirationRefusal(answers, options)\n if (inspiration) errors.inspiration = { message: inspiration, code: 'invalid' }\n // Every extra is optional; one given is held to its own rule (AGL-3979).\n for (const field of Object.values(AI_SITE_START_STEP_FIELDS).flat()) {\n if (field in errors) continue\n const problem = isExtra(field) ? aiSiteStartExtraProblem(field, answers[field] ?? '') : null\n if (problem) errors[field] = problem\n }\n return errors\n}\n\nconst CORE_FIELDS: ReadonlySet<AiSiteStartField> = new Set([\n 'siteType',\n 'audience',\n 'kind',\n 'inspiration',\n 'pages',\n 'submissions',\n 'welcomeEmail',\n])\n\nfunction isExtra(field: AiSiteStartField): field is AiSiteStartExtraKey {\n return !CORE_FIELDS.has(field)\n}\n\n/** One step's reasons not to go forward, in the order its fields are drawn; empty when it may. */\nexport function aiSiteStartStepErrors(\n step: AiSiteStartStep,\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): Array<{ field: AiSiteStartField } & AiSiteStartFieldError> {\n const errors = aiSiteStartFieldErrors(answers, options)\n return AI_SITE_START_STEP_FIELDS[step].flatMap((field) => {\n const error = errors[field]\n return error ? [{ field, ...error }] : []\n })\n}\n\n/** The first step holding an answer that cannot go forward, for a review that was reached with one; `null` for none. */\nexport function aiSiteStartFirstInvalidStep(\n answers: AiSiteStartAnswers,\n options: { freeTaste?: boolean } = {},\n): AiSiteStartStep | null {\n return AI_SITE_START_STEPS.find((step) => aiSiteStartStepErrors(step, answers, options).length > 0) ?? null\n}\n"],"names":["AI_SITE_INPUT_MAX_CHARS","AI_SITE_SUBMISSIONS","aiSitePagesBand","aiSiteStartExtraProblem","aiSiteStartInspirationRefusal","AI_SITE_START_STEPS","AI_SITE_START_STEP_LABELS","choose","kind","audience","style","details","review","AI_SITE_START_MORE_FIELDS","AI_SITE_START_STEP_FIELDS","aiSiteStartStepIndex","step","indexOf","aiSiteStartNextStep","aiSiteStartPreviousStep","index","aiSiteStartFieldErrors","answers","options","errors","siteType","trim","message","code","length","band","freeTaste","Number","isInteger","pages","min","max","includes","submissions","inspiration","field","Object","values","flat","problem","isExtra","CORE_FIELDS","Set","has","aiSiteStartStepErrors","flatMap","error","aiSiteStartFirstInvalidStep","find"],"mappings":"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"}
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { ClientBuildIntake } from '@aglyn/aglyn/app-utils/client-build';
|
|
18
|
+
import { type AiSiteDetailKey, type AiSiteDetails } from './ai-site-details';
|
|
17
19
|
import { type AiSiteKind } from './ai-site-kinds';
|
|
18
20
|
import { type AiSiteJobInputs, type AiSiteSubmissions } from './ai-site-job';
|
|
19
21
|
/**
|
|
@@ -73,8 +75,14 @@ export declare const AI_SITE_START_EXAMPLES: readonly AiSiteStartExample[];
|
|
|
73
75
|
* building is theirs to say, and a list of eight is a list somebody is not on.
|
|
74
76
|
*/
|
|
75
77
|
export declare const AI_SITE_START_TYPES: readonly string[];
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
+
/**
|
|
79
|
+
* What a person answered; the shape the card holds and the brief is built from.
|
|
80
|
+
*
|
|
81
|
+
* Beside the answers each step asks, the business details its "More details"
|
|
82
|
+
* offers (AGL-3979, `AiSiteDetails`): every one optional and `''` until given,
|
|
83
|
+
* the phone number and email on the Details step itself.
|
|
84
|
+
*/
|
|
85
|
+
export interface AiSiteStartAnswers extends AiSiteDetails {
|
|
78
86
|
/** What kind of site this is. The one answer with no default. */
|
|
79
87
|
siteType: string;
|
|
80
88
|
/** Who the site is for; empty when they did not say. */
|
|
@@ -93,6 +101,15 @@ export interface AiSiteStartAnswers {
|
|
|
93
101
|
submissions: AiSiteSubmissions;
|
|
94
102
|
/** Whether the scaffold also drafts a welcome email. */
|
|
95
103
|
welcomeEmail: boolean;
|
|
104
|
+
/** The city or town the business is in (AGL-3979); empty when they did not say. */
|
|
105
|
+
city: string;
|
|
106
|
+
/** The brand color, as a six-digit hex (AGL-3979); empty for the style's own. */
|
|
107
|
+
brand: string;
|
|
108
|
+
/**
|
|
109
|
+
* The sites they like (AGL-3975), as typed, one a field; absent or empty
|
|
110
|
+
* for none. Asked only while `release_ai_site_inspiration` is on.
|
|
111
|
+
*/
|
|
112
|
+
inspiration?: string[];
|
|
96
113
|
}
|
|
97
114
|
/**
|
|
98
115
|
* What the questions start on. Five pages because it is the middle of the
|
|
@@ -102,6 +119,17 @@ export interface AiSiteStartAnswers {
|
|
|
102
119
|
* thing a person should have to choose.
|
|
103
120
|
*/
|
|
104
121
|
export declare const AI_SITE_START_ANSWERS: AiSiteStartAnswers;
|
|
122
|
+
/** The answers a person may give beyond the ones each step asks, by key (AGL-3979). */
|
|
123
|
+
export type AiSiteStartExtraKey = AiSiteDetailKey | 'city' | 'brand';
|
|
124
|
+
/** Every extra, in the order the review lists them. */
|
|
125
|
+
export declare const AI_SITE_START_EXTRA_KEYS: readonly AiSiteStartExtraKey[];
|
|
126
|
+
/** What is wrong with one extra, worded for beside its field; `null` when it may be used. */
|
|
127
|
+
export declare function aiSiteStartExtraProblem(key: AiSiteStartExtraKey, value: string): {
|
|
128
|
+
code: 'too_long' | 'invalid';
|
|
129
|
+
message: string;
|
|
130
|
+
} | null;
|
|
131
|
+
/** The extras a person gave, in the review's order: what "More details" was used for. */
|
|
132
|
+
export declare function aiSiteStartExtrasGiven(answers: AiSiteStartAnswers): AiSiteStartExtraKey[];
|
|
105
133
|
/** The kind of site the answers pick, else the one what the site is and who it is for suggest. */
|
|
106
134
|
export declare function aiSiteStartKind(answers: Pick<AiSiteStartAnswers, 'kind' | 'siteType'> & Partial<Pick<AiSiteStartAnswers, 'audience'>>): AiSiteKind;
|
|
107
135
|
/** The example an answer names, or `null` when it names none this list knows. */
|
|
@@ -114,6 +142,12 @@ export declare function aiSiteStartExample(answers: AiSiteStartAnswers): AiSiteS
|
|
|
114
142
|
export declare function aiSiteStartRefusal(answers: AiSiteStartAnswers, options?: {
|
|
115
143
|
freeTaste?: boolean;
|
|
116
144
|
}): string | null;
|
|
145
|
+
/** The sites they typed, without the empty fields. */
|
|
146
|
+
export declare function aiSiteStartInspirationTyped(answers: Pick<AiSiteStartAnswers, 'inspiration'>): string[];
|
|
147
|
+
/** Why the sites they like cannot go forward, as the door would refuse them; `null` when they can. */
|
|
148
|
+
export declare function aiSiteStartInspirationRefusal(answers: Pick<AiSiteStartAnswers, 'inspiration'>, options?: {
|
|
149
|
+
freeTaste?: boolean;
|
|
150
|
+
}): string | null;
|
|
117
151
|
/**
|
|
118
152
|
* The answers as the brief the `site` job is created with: the plan step reads
|
|
119
153
|
* it as the person's own words, so it says what they said and asks for nothing
|
|
@@ -133,9 +167,11 @@ export declare function aiSiteStartBrief(answers: AiSiteStartAnswers): string;
|
|
|
133
167
|
* reached the model with no name had the model invent one for its header and
|
|
134
168
|
* footer.
|
|
135
169
|
*/
|
|
136
|
-
export declare function aiSiteStartInputs(answers: AiSiteStartAnswers): Omit<AiSiteJobInputs, 'batchId'
|
|
170
|
+
export declare function aiSiteStartInputs(answers: AiSiteStartAnswers): Omit<AiSiteJobInputs, 'batchId' | 'inspiration' | AiSiteDetailKey> & Partial<AiSiteDetails> & {
|
|
171
|
+
inspiration?: string;
|
|
172
|
+
};
|
|
137
173
|
/** The site inputs a guided start's answers become, by name: what a summary carries back (AGL-3596). */
|
|
138
|
-
export declare const AI_SITE_START_INPUT_KEYS: readonly ["businessType", "audience", "starter", "siteKind", "pages", "businessName", "city", "brand", "submissions", "welcomeEmail"];
|
|
174
|
+
export declare const AI_SITE_START_INPUT_KEYS: readonly ["businessType", "audience", "starter", "siteKind", "pages", "businessName", "city", "brand", "submissions", "welcomeEmail", "phone", "email", "tagline", "founded", "serviceArea", "priceRange", "languages", "street", "region", "postalCode", "country", "hours", "social", "inspiration"];
|
|
139
175
|
/** A site job's scalar inputs, and nothing else its inputs carry; `null` when it has none. */
|
|
140
176
|
export declare function aiSiteStartInputsOf(inputs: Readonly<Record<string, unknown>> | null | undefined): Record<string, unknown> | null;
|
|
141
177
|
/**
|
|
@@ -148,3 +184,44 @@ export declare function aiSiteStartInputsOf(inputs: Readonly<Record<string, unkn
|
|
|
148
184
|
export declare function aiSiteStartAnswersFromInputs(inputs: Readonly<Record<string, unknown>> | null | undefined, options?: {
|
|
149
185
|
freeTaste?: boolean;
|
|
150
186
|
}): AiSiteStartAnswers | null;
|
|
187
|
+
/**
|
|
188
|
+
* The questions' answers for a site staff are building for a client: what
|
|
189
|
+
* the business does is the kind of site, and the details the intake holds
|
|
190
|
+
* fill the Details step's own fields (AGL-3979, AGL-3995): the phone, the
|
|
191
|
+
* email, the towns as the areas it serves and the hours, each held to that
|
|
192
|
+
* detail's own rule, so one the Details step would refuse is left empty
|
|
193
|
+
* there. Every other answer keeps its default, which the review shows to
|
|
194
|
+
* change. The intake, a refused detail included, also reaches the job
|
|
195
|
+
* through {@link aiSiteStartClientInputs} and {@link aiSiteStartClientBrief}.
|
|
196
|
+
*/
|
|
197
|
+
export declare function aiSiteStartAnswersFromClientBuild(intake: ClientBuildIntake): AiSiteStartAnswers;
|
|
198
|
+
/**
|
|
199
|
+
* The per-site inputs the intake fills, which the guided start's own
|
|
200
|
+
* questions leave empty: the business's name for the header and footer, and
|
|
201
|
+
* the towns it serves as its place.
|
|
202
|
+
*
|
|
203
|
+
* The towns are the place only while no address is given. The Entity reads
|
|
204
|
+
* `city` as the address's own locality wherever there is a street or a
|
|
205
|
+
* postal code (`aiSiteEntity`), so once the answers carry either, `city` is
|
|
206
|
+
* the city the Details step was given with that address, never the towns
|
|
207
|
+
* (AGL-3995). The intake asks no address, so today that is the Details step's
|
|
208
|
+
* own, typed on the review; the towns still reach the site as the areas it
|
|
209
|
+
* serves.
|
|
210
|
+
*/
|
|
211
|
+
export declare function aiSiteStartClientInputs(intake: ClientBuildIntake, answers?: Pick<AiSiteStartAnswers, 'street' | 'postalCode' | 'city'> | null): Pick<AiSiteJobInputs, 'businessName' | 'city'>;
|
|
212
|
+
/**
|
|
213
|
+
* The intake as sentences added to the brief: how to reach the business and
|
|
214
|
+
* when it is open, which the site should say where a visitor looks for them,
|
|
215
|
+
* and the owner's own words, quoted, which the copy should sound like. Empty
|
|
216
|
+
* answers say nothing.
|
|
217
|
+
*/
|
|
218
|
+
export declare function aiSiteStartClientBrief(intake: ClientBuildIntake): string;
|
|
219
|
+
/**
|
|
220
|
+
* What the review says the client build adds beyond its own rows, so staff
|
|
221
|
+
* see the intake is used without it being asked again: the business's name
|
|
222
|
+
* and the owner's own words, which no row shows. The phone, email, towns and
|
|
223
|
+
* hours are the review's Details rows already (AGL-3995), so the note does
|
|
224
|
+
* not say them twice: "Also from the client's details: Harbor Plumbing and
|
|
225
|
+
* the owner's own words."
|
|
226
|
+
*/
|
|
227
|
+
export declare function aiSiteStartClientNote(intake: ClientBuildIntake): string;
|