@aglyn/plugins-ai 1.0.0-beta.227 → 1.0.0-beta.229
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/README.md +1 -1
- package/package.json +17 -17
- package/src/lib/components/ai-brief-dialog.component.js +1 -1
- package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-describe-automation.component.js +7 -2
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-describe-button.component.js +6 -1
- package/src/lib/components/ai-describe-button.component.js.map +1 -1
- package/src/lib/components/ai-describe-page.component.d.ts +1 -1
- package/src/lib/components/ai-describe-page.component.js +1 -1
- package/src/lib/components/ai-describe-page.component.js.map +1 -1
- package/src/lib/components/ai-job-events.d.ts +7 -1
- package/src/lib/components/ai-job-events.js +4 -2
- package/src/lib/components/ai-job-events.js.map +1 -1
- package/src/lib/components/ai-job-site.d.ts +22 -0
- package/src/lib/components/ai-job-site.js +73 -0
- package/src/lib/components/ai-job-site.js.map +1 -0
- package/src/lib/components/ai-jobs-list-page.component.d.ts +23 -0
- package/src/lib/components/ai-jobs-list-page.component.js +314 -0
- package/src/lib/components/ai-jobs-list-page.component.js.map +1 -0
- package/src/lib/components/ai-jobs-page.component.d.ts +7 -0
- package/src/lib/components/ai-jobs-page.component.js +30 -0
- package/src/lib/components/ai-jobs-page.component.js.map +1 -0
- package/src/lib/components/ai-page-brief-dialog.component.d.ts +1 -1
- package/src/lib/components/ai-page-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.d.ts +31 -3
- package/src/lib/components/ai-site-build-page.component.js +394 -243
- package/src/lib/components/ai-site-build-page.component.js.map +1 -1
- package/src/lib/components/ai-site-start-card.component.d.ts +18 -1
- package/src/lib/components/ai-site-start-card.component.js +27 -15
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/assist-jobs-drawer.component.d.ts +11 -0
- package/src/lib/components/assist-jobs-drawer.component.js +37 -1
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +9 -4
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/jobs/ai-job-draft-ids.d.ts +6 -0
- package/src/lib/jobs/ai-job-draft-ids.js +9 -0
- package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
- package/src/lib/jobs/ai-job-drafts.d.ts +7 -0
- package/src/lib/jobs/ai-job-drafts.js +16 -7
- package/src/lib/jobs/ai-job-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-layout-site-pages.d.ts +70 -0
- package/src/lib/jobs/ai-job-layout-site-pages.js +216 -0
- package/src/lib/jobs/ai-job-layout-site-pages.js.map +1 -0
- package/src/lib/jobs/ai-job-layout-step.d.ts +2 -2
- package/src/lib/jobs/ai-job-layout-step.js +21 -2
- package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
- package/src/lib/jobs/ai-job-page-sections.d.ts +6 -5
- package/src/lib/jobs/ai-job-page-sections.js +3 -3
- package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.d.ts +19 -1
- package/src/lib/jobs/ai-job-page-step.js +50 -8
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.js +7 -2
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +21 -0
- package/src/lib/jobs/ai-job-site-step.js +103 -17
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +7 -1
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.d.ts +19 -1
- package/src/lib/jobs/ai-jobs.js +130 -53
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-site-publish.d.ts +98 -0
- package/src/lib/jobs/ai-site-publish.js +372 -0
- package/src/lib/jobs/ai-site-publish.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-page-briefs.d.ts +7 -6
- package/src/lib/jobs/fixtures/ai-page-briefs.js +23 -56
- package/src/lib/jobs/fixtures/ai-page-briefs.js.map +1 -1
- package/src/lib/model/ai-job-failure-copy.d.ts +20 -1
- package/src/lib/model/ai-job-failure-copy.js +28 -1
- package/src/lib/model/ai-job-failure-copy.js.map +1 -1
- package/src/lib/model/ai-job-notice.d.ts +6 -1
- package/src/lib/model/ai-job-notice.js +29 -3
- package/src/lib/model/ai-job-notice.js.map +1 -1
- package/src/lib/model/ai-jobs.types.d.ts +34 -0
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-plan-capabilities.d.ts +25 -10
- package/src/lib/model/ai-plan-capabilities.js +15 -2
- package/src/lib/model/ai-plan-capabilities.js.map +1 -1
- package/src/lib/model/ai-site-build-progress.d.ts +25 -1
- package/src/lib/model/ai-site-build-progress.js +129 -15
- package/src/lib/model/ai-site-build-progress.js.map +1 -1
- package/src/lib/model/ai-site-start-seo.d.ts +35 -0
- package/src/lib/model/ai-site-start-seo.js +145 -7
- package/src/lib/model/ai-site-start-seo.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +14 -0
- package/src/lib/model/ai-site-start.js +43 -0
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/plugin.js +18 -10
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +16 -12
- package/src/lib/runtime/ai-doctrine-validators.js +19 -36
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.d.ts +6 -0
- package/src/lib/runtime/ai-doctrine.js +6 -2
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-eval-live.d.ts +1 -1
- package/src/lib/runtime/ai-eval-live.js +1 -1
- package/src/lib/runtime/ai-eval-live.js.map +1 -1
- package/src/lib/server/ai-jobs-live-sites.d.ts +26 -0
- package/src/lib/server/ai-jobs-live-sites.js +58 -0
- package/src/lib/server/ai-jobs-live-sites.js.map +1 -0
- package/src/lib/server/ai-jobs-route.js +22 -3
- package/src/lib/server/ai-jobs-route.js.map +1 -1
- package/src/lib/usage/assist-job-refund.d.ts +3 -2
- package/src/lib/usage/assist-job-refund.js +2 -1
- package/src/lib/usage/assist-job-refund.js.map +1 -1
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/
|
|
16
|
+
*/ import { aiJobKindNoun } from "./ai-job-activity.js";
|
|
17
|
+
import { aiJobRefundCopy } from "./ai-job-failure-copy.js";
|
|
18
|
+
export function aiSiteBuildPhase(job) {
|
|
17
19
|
switch(job.status){
|
|
18
20
|
case 'done':
|
|
19
21
|
return 'done';
|
|
@@ -28,32 +30,82 @@
|
|
|
28
30
|
return 'working';
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
/** The creations a scaffold builds before its pages, in its build order, and what each row says. */ const CREATION_ROWS = [
|
|
34
|
+
{
|
|
35
|
+
kind: 'theme-change',
|
|
36
|
+
resource: 'theme',
|
|
37
|
+
label: ()=>'Choosing your colors'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: 'layout',
|
|
41
|
+
resource: 'layout',
|
|
42
|
+
label: (name)=>`Building the header and footer: ${name}`
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: 'form',
|
|
46
|
+
resource: 'form',
|
|
47
|
+
label: (name)=>`Building the form: ${name}`
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* One row a stage: planning, then each creation the plan builds before its
|
|
52
|
+
* pages — the colors, the layout, the form — then each page (AGL-3596). A
|
|
53
|
+
* scaffold builds them in that order and reports each as an output when it
|
|
54
|
+
* is written, so the first row without its output is where the build is, and
|
|
55
|
+
* where a failure stopped it: a layout that broke is the layout's row, never
|
|
56
|
+
* the first page's.
|
|
57
|
+
*/ export function aiSiteBuildRows(job) {
|
|
58
|
+
var _built_get, _ref;
|
|
33
59
|
var _job_plan;
|
|
34
60
|
const phase = aiSiteBuildPhase(job);
|
|
35
61
|
const planStep = job.steps.find((step)=>step.name === 'plan');
|
|
36
62
|
const planDone = (planStep == null ? void 0 : planStep.status) === 'done' || Boolean(job.plan);
|
|
37
|
-
const
|
|
38
|
-
const planActive = !planDone;
|
|
63
|
+
const stopped = phase === 'failed' || phase === 'stopped';
|
|
39
64
|
const rows = [
|
|
40
65
|
{
|
|
41
66
|
id: 'plan',
|
|
42
67
|
label: 'Planning your pages',
|
|
43
|
-
state: planDone ? 'done' :
|
|
68
|
+
state: planDone ? 'done' : stopped ? 'failed' : phase === 'working' ? 'active' : 'waiting'
|
|
44
69
|
}
|
|
45
70
|
];
|
|
71
|
+
const built = new Map();
|
|
72
|
+
for (const output of job.outputs)built.set(output.resource, ((_built_get = built.get(output.resource)) != null ? _built_get : 0) + 1);
|
|
73
|
+
const take = (resource)=>{
|
|
74
|
+
var _built_get;
|
|
75
|
+
const left = (_built_get = built.get(resource)) != null ? _built_get : 0;
|
|
76
|
+
if (left <= 0) return false;
|
|
77
|
+
built.set(resource, left - 1);
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
const stages = [];
|
|
81
|
+
for (const row of CREATION_ROWS){
|
|
82
|
+
var _job_plan1;
|
|
83
|
+
const creation = (_job_plan1 = job.plan) == null ? void 0 : _job_plan1.create.find((entry)=>entry.kind === row.kind);
|
|
84
|
+
if (creation) stages.push({
|
|
85
|
+
id: row.resource,
|
|
86
|
+
label: row.label(creation.name),
|
|
87
|
+
resource: row.resource
|
|
88
|
+
});
|
|
89
|
+
}
|
|
46
90
|
const pages = (_ref = (_job_plan = job.plan) == null ? void 0 : _job_plan.screens) != null ? _ref : [];
|
|
47
|
-
const written = job.outputs.filter((output)=>output.resource === 'screen').length;
|
|
48
91
|
pages.forEach((page, index)=>{
|
|
49
|
-
|
|
50
|
-
const current = !done && index === written && planDone;
|
|
51
|
-
rows.push({
|
|
92
|
+
stages.push({
|
|
52
93
|
id: `page-${index}`,
|
|
53
94
|
label: `Writing page ${index + 1} of ${pages.length}: ${page.title}`,
|
|
54
|
-
|
|
95
|
+
resource: 'screen'
|
|
55
96
|
});
|
|
56
97
|
});
|
|
98
|
+
let reached = false;
|
|
99
|
+
for (const stage of stages){
|
|
100
|
+
const done = phase === 'done' || take(stage.resource);
|
|
101
|
+
const current = !done && !reached && planDone;
|
|
102
|
+
if (current) reached = true;
|
|
103
|
+
rows.push({
|
|
104
|
+
id: stage.id,
|
|
105
|
+
label: stage.label,
|
|
106
|
+
state: done ? 'done' : current && stopped ? 'failed' : current && phase === 'working' ? 'active' : 'waiting'
|
|
107
|
+
});
|
|
108
|
+
}
|
|
57
109
|
return rows;
|
|
58
110
|
}
|
|
59
111
|
/**
|
|
@@ -66,11 +118,73 @@ export function aiSiteBuildRows(job) {
|
|
|
66
118
|
const refunded = Math.max(0, (_job_refundedCredits = job.refundedCredits) != null ? _job_refundedCredits : 0);
|
|
67
119
|
const net = Math.max(0, spent - refunded);
|
|
68
120
|
const phase = aiSiteBuildPhase(job);
|
|
69
|
-
|
|
70
|
-
|
|
121
|
+
// What the job made, by its kind; a job that names none is a site's.
|
|
122
|
+
const subject = !job.kind || job.kind === 'site' ? 'This site' : aiJobKindNoun(job.kind) === 'AI job' ? 'This AI job' : `This ${aiJobKindNoun(job.kind)}`;
|
|
123
|
+
// A failure on our side says so in the words every surface uses (AGL-3596),
|
|
124
|
+
// from the job's recorded give-back.
|
|
125
|
+
const refund = phase === 'working' || phase === 'done' ? null : aiJobRefundCopy(job);
|
|
126
|
+
if (refund && refunded > 0) return refund;
|
|
127
|
+
if (phase === 'done') return `${subject} used ${net} credits.`;
|
|
71
128
|
if (phase === 'working') return `Credits used so far: ${net}`;
|
|
72
|
-
if (
|
|
73
|
-
return net > 0 ?
|
|
129
|
+
if (refund) return `${subject} used ${net} credits. ${refund}`;
|
|
130
|
+
return net > 0 ? `${subject} used ${net} credits.` : null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* What one job's page says at the top (AGL-3594, AGL-3596). A site job reads
|
|
134
|
+
* as "Building your site"; any other kind names what it makes — "Building
|
|
135
|
+
* your page" — or, for a kind with no noun, the job itself.
|
|
136
|
+
*
|
|
137
|
+
* A guided site start publishes its pages when it finishes, and says so
|
|
138
|
+
* (`sitePublish`): with a page live the heading is "Your site is live". A
|
|
139
|
+
* done site job without that record — an older job, or one that was not a
|
|
140
|
+
* guided start — built drafts, and says that instead.
|
|
141
|
+
*/ export function aiJobPageCopy(job, brand) {
|
|
142
|
+
var _ref, _ref1;
|
|
143
|
+
var _job_review;
|
|
144
|
+
const phase = aiSiteBuildPhase(job);
|
|
145
|
+
const noun = job.kind === 'site' ? 'site' : aiJobKindNoun(job.kind);
|
|
146
|
+
const generic = noun === 'AI job';
|
|
147
|
+
// `products` is the one plural noun.
|
|
148
|
+
const isAre = noun.endsWith('s') ? 'are' : 'is';
|
|
149
|
+
const why = (_ref = (_ref1 = (_job_review = job.review) == null ? void 0 : _job_review.message) != null ? _ref1 : job.error) != null ? _ref : null;
|
|
150
|
+
if (phase === 'working') {
|
|
151
|
+
return {
|
|
152
|
+
heading: generic ? 'Your AI job is running' : `Building your ${noun}`,
|
|
153
|
+
lede: job.kind === 'site' ? `${brand} AI is planning your pages and writing each one. You can leave this page; it keeps going.` : `${brand} AI is working on it. You can leave this page; it keeps going.`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (phase === 'done') {
|
|
157
|
+
var _ref2;
|
|
158
|
+
var _job_sitePublish;
|
|
159
|
+
const published = (_ref2 = (_job_sitePublish = job.sitePublish) == null ? void 0 : _job_sitePublish.published.length) != null ? _ref2 : 0;
|
|
160
|
+
if (job.kind === 'site' && published > 0) {
|
|
161
|
+
var _job_sitePublish1;
|
|
162
|
+
return {
|
|
163
|
+
heading: 'Your site is live',
|
|
164
|
+
lede: ((_job_sitePublish1 = job.sitePublish) == null ? void 0 : _job_sitePublish1.drafts.length) ? 'Your pages are published, except the ones listed below, which stayed drafts.' : 'Your pages are published, and anyone can visit your site now.'
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (job.kind === 'site' && job.sitePublish) {
|
|
168
|
+
return {
|
|
169
|
+
heading: 'Your site is ready',
|
|
170
|
+
lede: 'None of your new pages could be published, so they are drafts. The reasons are below.'
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
heading: generic ? 'Your AI job finished' : `Your ${noun} ${isAre} ready`,
|
|
175
|
+
lede: job.kind === 'site' ? 'Your new pages are drafts. Publish them when you’re happy.' : 'Everything it built is an unpublished draft until you publish it.'
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (phase === 'canceled') {
|
|
179
|
+
return {
|
|
180
|
+
heading: generic ? 'Your AI job was canceled' : `Your ${noun} was not built`,
|
|
181
|
+
lede: 'The job was canceled.'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
heading: generic ? 'Your AI job stopped' : `Your ${noun} ${noun.endsWith('s') ? 'were' : 'was'} not built`,
|
|
186
|
+
lede: why != null ? why : generic ? 'Something went wrong running this job.' : `Something went wrong building your ${noun}.`
|
|
187
|
+
};
|
|
74
188
|
}
|
|
75
189
|
|
|
76
190
|
//# sourceMappingURL=ai-site-build-progress.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-build-progress.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 type { AiJobSummary } from './ai-jobs.types'\n\n/**\n * What the \"Building your site\" page shows of a site job (AGL-3594): one row\n * a stage — planning the pages, then each page — with its state, the state\n * the whole job is in, and the job's one price.\n *\n * Read off the job summary every surface already follows: the plan step's\n * status, the plan's pages, and the pages the build has reported as outputs.\n * A scaffold builds its units in plan order and reports each page when it is\n * written, so the pages written so far are the first pages of the plan.\n *\n * Pure: no React, no request. The page and its spec read the same rows.\n */\n\nexport type AiSiteBuildRowState = 'done' | 'active' | 'waiting' | 'failed'\n\nexport interface AiSiteBuildRow {\n id: string\n label: string\n state: AiSiteBuildRowState\n}\n\n/** Where the whole job stands, as the page's header and actions read it. */\nexport type AiSiteBuildPhase = 'working' | 'done' | 'stopped' | 'failed' | 'canceled'\n\nexport function aiSiteBuildPhase(job: Pick<AiJobSummary, 'status'>): AiSiteBuildPhase {\n switch (job.status) {\n case 'done':\n return 'done'\n case 'failed':\n return 'failed'\n case 'canceled':\n return 'canceled'\n case 'needs_review':\n case 'needs_input':\n return 'stopped'\n default:\n return 'working'\n }\n}\n\nexport function aiSiteBuildRows(\n job: Pick<AiJobSummary, 'status' | 'steps' | 'plan' | 'outputs' | 'review'>,\n): AiSiteBuildRow[] {\n const phase = aiSiteBuildPhase(job)\n const planStep = job.steps.find((step) => step.name === 'plan')\n const planDone = planStep?.status === 'done' || Boolean(job.plan)\n const stoppedHere = (active: boolean) => (phase === 'failed' || phase === 'stopped') && active\n const planActive = !planDone\n const rows: AiSiteBuildRow[] = [\n {\n id: 'plan',\n label: 'Planning your pages',\n state: planDone ? 'done' : stoppedHere(planActive) ? 'failed' : phase === 'working' ? 'active' : 'waiting',\n },\n ]\n const pages = job.plan?.screens ?? []\n const written = job.outputs.filter((output) => output.resource === 'screen').length\n pages.forEach((page, index) => {\n const done = index < written || phase === 'done'\n const current = !done && index === written && planDone\n rows.push({\n id: `page-${index}`,\n label: `Writing page ${index + 1} of ${pages.length}: ${page.title}`,\n state: done ? 'done' : stoppedHere(current) ? 'failed' : current && phase === 'working' ? 'active' : 'waiting',\n })\n })\n return rows\n}\n\n/**\n * The job's one price (AGL-3594), never a plan's on its own: what it has used\n * so far while it works, what it used once done, and, on a failure we gave\n * back, that nothing was charged.\n */\nexport function aiSiteBuildCreditsLine(\n job: Pick<AiJobSummary, 'status' | 'creditsSpent' | 'refundedCredits'>,\n): string | null {\n const spent = Math.max(0, job.creditsSpent ?? 0)\n const refunded = Math.max(0, job.refundedCredits ?? 0)\n const net = Math.max(0, spent - refunded)\n const phase = aiSiteBuildPhase(job)\n if (refunded > 0 && net === 0) return `Nothing was charged — we refunded ${refunded} credits.`\n if (phase === 'done') return `This site used ${net} credits.`\n if (phase === 'working') return `Credits used so far: ${net}`\n if (refunded > 0) return `This site used ${net} credits — we refunded ${refunded}.`\n return net > 0 ? `This site used ${net} credits.` : null\n}\n"],"names":["aiSiteBuildPhase","job","status","aiSiteBuildRows","phase","planStep","steps","find","step","name","planDone","Boolean","plan","stoppedHere","active","planActive","rows","id","label","state","pages","screens","written","outputs","filter","output","resource","length","forEach","page","index","done","current","push","title","aiSiteBuildCreditsLine","spent","Math","max","creditsSpent","refunded","refundedCredits","net"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA4BD,OAAO,SAASA,iBAAiBC,GAAiC;IAChE,OAAQA,IAAIC,MAAM;QAChB,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;QACL,KAAK;YACH,OAAO;QACT;YACE,OAAO;IACX;AACF;AAEA,OAAO,SAASC,gBACdF,GAA2E;;QAc7DA;IAZd,MAAMG,QAAQJ,iBAAiBC;IAC/B,MAAMI,WAAWJ,IAAIK,KAAK,CAACC,IAAI,CAAC,CAACC,OAASA,KAAKC,IAAI,KAAK;IACxD,MAAMC,WAAWL,CAAAA,4BAAAA,SAAUH,MAAM,MAAK,UAAUS,QAAQV,IAAIW,IAAI;IAChE,MAAMC,cAAc,CAACC,SAAoB,AAACV,CAAAA,UAAU,YAAYA,UAAU,SAAQ,KAAMU;IACxF,MAAMC,aAAa,CAACL;IACpB,MAAMM,OAAyB;QAC7B;YACEC,IAAI;YACJC,OAAO;YACPC,OAAOT,WAAW,SAASG,YAAYE,cAAc,WAAWX,UAAU,YAAY,WAAW;QACnG;KACD;IACD,MAAMgB,iBAAQnB,YAAAA,IAAIW,IAAI,qBAARX,UAAUoB,OAAO,mBAAI,EAAE;IACrC,MAAMC,UAAUrB,IAAIsB,OAAO,CAACC,MAAM,CAAC,CAACC,SAAWA,OAAOC,QAAQ,KAAK,UAAUC,MAAM;IACnFP,MAAMQ,OAAO,CAAC,CAACC,MAAMC;QACnB,MAAMC,OAAOD,QAAQR,WAAWlB,UAAU;QAC1C,MAAM4B,UAAU,CAACD,QAAQD,UAAUR,WAAWZ;QAC9CM,KAAKiB,IAAI,CAAC;YACRhB,IAAI,CAAC,KAAK,EAAEa,OAAO;YACnBZ,OAAO,CAAC,aAAa,EAAEY,QAAQ,EAAE,IAAI,EAAEV,MAAMO,MAAM,CAAC,EAAE,EAAEE,KAAKK,KAAK,EAAE;YACpEf,OAAOY,OAAO,SAASlB,YAAYmB,WAAW,WAAWA,WAAW5B,UAAU,YAAY,WAAW;QACvG;IACF;IACA,OAAOY;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASmB,uBACdlC,GAAsE;QAE5CA,mBACGA;IAD7B,MAAMmC,QAAQC,KAAKC,GAAG,CAAC,IAAGrC,oBAAAA,IAAIsC,YAAY,YAAhBtC,oBAAoB;IAC9C,MAAMuC,WAAWH,KAAKC,GAAG,CAAC,IAAGrC,uBAAAA,IAAIwC,eAAe,YAAnBxC,uBAAuB;IACpD,MAAMyC,MAAML,KAAKC,GAAG,CAAC,GAAGF,QAAQI;IAChC,MAAMpC,QAAQJ,iBAAiBC;IAC/B,IAAIuC,WAAW,KAAKE,QAAQ,GAAG,OAAO,CAAC,kCAAkC,EAAEF,SAAS,SAAS,CAAC;IAC9F,IAAIpC,UAAU,QAAQ,OAAO,CAAC,eAAe,EAAEsC,IAAI,SAAS,CAAC;IAC7D,IAAItC,UAAU,WAAW,OAAO,CAAC,qBAAqB,EAAEsC,KAAK;IAC7D,IAAIF,WAAW,GAAG,OAAO,CAAC,eAAe,EAAEE,IAAI,uBAAuB,EAAEF,SAAS,CAAC,CAAC;IACnF,OAAOE,MAAM,IAAI,CAAC,eAAe,EAAEA,IAAI,SAAS,CAAC,GAAG;AACtD"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-build-progress.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 { aiJobKindNoun } from './ai-job-activity'\nimport { aiJobRefundCopy } from './ai-job-failure-copy'\nimport type { AiJobSummary } from './ai-jobs.types'\n\n/**\n * What the \"Building your site\" page shows of a site job (AGL-3594): one row\n * a stage — planning the pages, then each page — with its state, the state\n * the whole job is in, and the job's one price.\n *\n * Read off the job summary every surface already follows: the plan step's\n * status, the plan's pages, and the pages the build has reported as outputs.\n * A scaffold builds its units in plan order and reports each page when it is\n * written, so the pages written so far are the first pages of the plan.\n *\n * Pure: no React, no request. The page and its spec read the same rows.\n */\n\nexport type AiSiteBuildRowState = 'done' | 'active' | 'waiting' | 'failed'\n\nexport interface AiSiteBuildRow {\n id: string\n label: string\n state: AiSiteBuildRowState\n}\n\n/** Where the whole job stands, as the page's header and actions read it. */\nexport type AiSiteBuildPhase = 'working' | 'done' | 'stopped' | 'failed' | 'canceled'\n\nexport function aiSiteBuildPhase(job: Pick<AiJobSummary, 'status'>): AiSiteBuildPhase {\n switch (job.status) {\n case 'done':\n return 'done'\n case 'failed':\n return 'failed'\n case 'canceled':\n return 'canceled'\n case 'needs_review':\n case 'needs_input':\n return 'stopped'\n default:\n return 'working'\n }\n}\n\n/** The creations a scaffold builds before its pages, in its build order, and what each row says. */\nconst CREATION_ROWS: ReadonlyArray<{ kind: string; resource: string; label: (name: string) => string }> = [\n { kind: 'theme-change', resource: 'theme', label: () => 'Choosing your colors' },\n { kind: 'layout', resource: 'layout', label: (name) => `Building the header and footer: ${name}` },\n { kind: 'form', resource: 'form', label: (name) => `Building the form: ${name}` },\n]\n\n/**\n * One row a stage: planning, then each creation the plan builds before its\n * pages — the colors, the layout, the form — then each page (AGL-3596). A\n * scaffold builds them in that order and reports each as an output when it\n * is written, so the first row without its output is where the build is, and\n * where a failure stopped it: a layout that broke is the layout's row, never\n * the first page's.\n */\nexport function aiSiteBuildRows(\n job: Pick<AiJobSummary, 'status' | 'steps' | 'plan' | 'outputs' | 'review'>,\n): AiSiteBuildRow[] {\n const phase = aiSiteBuildPhase(job)\n const planStep = job.steps.find((step) => step.name === 'plan')\n const planDone = planStep?.status === 'done' || Boolean(job.plan)\n const stopped = phase === 'failed' || phase === 'stopped'\n const rows: AiSiteBuildRow[] = [\n {\n id: 'plan',\n label: 'Planning your pages',\n state: planDone ? 'done' : stopped ? 'failed' : phase === 'working' ? 'active' : 'waiting',\n },\n ]\n const built = new Map<string, number>()\n for (const output of job.outputs) built.set(output.resource, (built.get(output.resource) ?? 0) + 1)\n const take = (resource: string): boolean => {\n const left = built.get(resource) ?? 0\n if (left <= 0) return false\n built.set(resource, left - 1)\n return true\n }\n const stages: Array<{ id: string; label: string; resource: string }> = []\n for (const row of CREATION_ROWS) {\n const creation = job.plan?.create.find((entry) => entry.kind === row.kind)\n if (creation) stages.push({ id: row.resource, label: row.label(creation.name), resource: row.resource })\n }\n const pages = job.plan?.screens ?? []\n pages.forEach((page, index) => {\n stages.push({ id: `page-${index}`, label: `Writing page ${index + 1} of ${pages.length}: ${page.title}`, resource: 'screen' })\n })\n let reached = false\n for (const stage of stages) {\n const done = phase === 'done' || take(stage.resource)\n const current = !done && !reached && planDone\n if (current) reached = true\n rows.push({\n id: stage.id,\n label: stage.label,\n state: done ? 'done' : current && stopped ? 'failed' : current && phase === 'working' ? 'active' : 'waiting',\n })\n }\n return rows\n}\n\n/**\n * The job's one price (AGL-3594), never a plan's on its own: what it has used\n * so far while it works, what it used once done, and, on a failure we gave\n * back, that nothing was charged.\n */\nexport function aiSiteBuildCreditsLine(\n job: Pick<AiJobSummary, 'status' | 'creditsSpent' | 'refundedCredits'> & Partial<Pick<AiJobSummary, 'kind'>>,\n): string | null {\n const spent = Math.max(0, job.creditsSpent ?? 0)\n const refunded = Math.max(0, job.refundedCredits ?? 0)\n const net = Math.max(0, spent - refunded)\n const phase = aiSiteBuildPhase(job)\n // What the job made, by its kind; a job that names none is a site's.\n const subject = !job.kind || job.kind === 'site' ? 'This site' : aiJobKindNoun(job.kind) === 'AI job' ? 'This AI job' : `This ${aiJobKindNoun(job.kind)}`\n // A failure on our side says so in the words every surface uses (AGL-3596),\n // from the job's recorded give-back.\n const refund = phase === 'working' || phase === 'done' ? null : aiJobRefundCopy(job)\n if (refund && refunded > 0) return refund\n if (phase === 'done') return `${subject} used ${net} credits.`\n if (phase === 'working') return `Credits used so far: ${net}`\n if (refund) return `${subject} used ${net} credits. ${refund}`\n return net > 0 ? `${subject} used ${net} credits.` : null\n}\n\n/** The heading and the sentence under it, for one job's page in its current phase. */\nexport interface AiJobPageCopy {\n heading: string\n lede: string\n}\n\n/**\n * What one job's page says at the top (AGL-3594, AGL-3596). A site job reads\n * as \"Building your site\"; any other kind names what it makes — \"Building\n * your page\" — or, for a kind with no noun, the job itself.\n *\n * A guided site start publishes its pages when it finishes, and says so\n * (`sitePublish`): with a page live the heading is \"Your site is live\". A\n * done site job without that record — an older job, or one that was not a\n * guided start — built drafts, and says that instead.\n */\nexport function aiJobPageCopy(\n job: Pick<AiJobSummary, 'kind' | 'status' | 'review' | 'error'> & Partial<Pick<AiJobSummary, 'sitePublish'>>,\n brand: string,\n): AiJobPageCopy {\n const phase = aiSiteBuildPhase(job)\n const noun = job.kind === 'site' ? 'site' : aiJobKindNoun(job.kind)\n const generic = noun === 'AI job'\n // `products` is the one plural noun.\n const isAre = noun.endsWith('s') ? 'are' : 'is'\n const why = job.review?.message ?? job.error ?? null\n if (phase === 'working') {\n return {\n heading: generic ? 'Your AI job is running' : `Building your ${noun}`,\n lede:\n job.kind === 'site'\n ? `${brand} AI is planning your pages and writing each one. You can leave this page; it keeps going.`\n : `${brand} AI is working on it. You can leave this page; it keeps going.`,\n }\n }\n if (phase === 'done') {\n const published = job.sitePublish?.published.length ?? 0\n if (job.kind === 'site' && published > 0) {\n return {\n heading: 'Your site is live',\n lede: job.sitePublish?.drafts.length\n ? 'Your pages are published, except the ones listed below, which stayed drafts.'\n : 'Your pages are published, and anyone can visit your site now.',\n }\n }\n if (job.kind === 'site' && job.sitePublish) {\n return {\n heading: 'Your site is ready',\n lede: 'None of your new pages could be published, so they are drafts. The reasons are below.',\n }\n }\n return {\n heading: generic ? 'Your AI job finished' : `Your ${noun} ${isAre} ready`,\n lede:\n job.kind === 'site'\n ? 'Your new pages are drafts. Publish them when you’re happy.'\n : 'Everything it built is an unpublished draft until you publish it.',\n }\n }\n if (phase === 'canceled') {\n return { heading: generic ? 'Your AI job was canceled' : `Your ${noun} was not built`, lede: 'The job was canceled.' }\n }\n return {\n heading: generic ? 'Your AI job stopped' : `Your ${noun} ${noun.endsWith('s') ? 'were' : 'was'} not built`,\n lede: why ?? (generic ? 'Something went wrong running this job.' : `Something went wrong building your ${noun}.`),\n }\n}\n"],"names":["aiJobKindNoun","aiJobRefundCopy","aiSiteBuildPhase","job","status","CREATION_ROWS","kind","resource","label","name","aiSiteBuildRows","built","phase","planStep","steps","find","step","planDone","Boolean","plan","stopped","rows","id","state","Map","output","outputs","set","get","take","left","stages","row","creation","create","entry","push","pages","screens","forEach","page","index","length","title","reached","stage","done","current","aiSiteBuildCreditsLine","spent","Math","max","creditsSpent","refunded","refundedCredits","net","subject","refund","aiJobPageCopy","brand","noun","generic","isAre","endsWith","why","review","message","error","heading","lede","published","sitePublish","drafts"],"mappings":"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"}
|
|
@@ -77,6 +77,39 @@ export interface AiSiteSeoProposal {
|
|
|
77
77
|
}
|
|
78
78
|
/** What the proposal says about itself wherever it is shown. */
|
|
79
79
|
export declare const AI_SITE_SEO_NOTE = "Written from what you said the site is and who it is for, not from its pages \u2014 read it before you save it.";
|
|
80
|
+
/**
|
|
81
|
+
* A short search title (AGL-3596): the business's name, what it is and where,
|
|
82
|
+
* held to the title cap and never cut mid-phrase. The answer to "what kind of
|
|
83
|
+
* site" is often a whole sentence, and cutting that sentence at sixty
|
|
84
|
+
* characters published "…that takes bookings for" as a site's title.
|
|
85
|
+
*
|
|
86
|
+
* The longest of these that fits wins: the whole answer; its first clause
|
|
87
|
+
* (the subject before "that", "for", a comma…); that clause without its
|
|
88
|
+
* place. The city the person gave is added where the words do not already
|
|
89
|
+
* say it, and the name leads where there is one. Only when even the shortest
|
|
90
|
+
* does not fit is it cut, at a word, never on a dangling one.
|
|
91
|
+
*/
|
|
92
|
+
export declare function aiSiteSeoTitle(input: {
|
|
93
|
+
name?: string;
|
|
94
|
+
subject: string;
|
|
95
|
+
city?: string;
|
|
96
|
+
}): string;
|
|
97
|
+
/**
|
|
98
|
+
* The site's search description (AGL-3596): what the site is, then who it is
|
|
99
|
+
* for, composed WITHIN the description cap at phrase boundaries rather than
|
|
100
|
+
* cut at it. Cut at 155 characters, "…for dog owners in Hillside and nearby
|
|
101
|
+
* towns who want a calm, gentle" went out as a site's description. So the
|
|
102
|
+
* longest whole composition that fits wins: all of both; the audience without
|
|
103
|
+
* its trailing clauses; what the site is alone; then what the site is, cut
|
|
104
|
+
* back the same way. Only when not even the subject fits is it clipped at a
|
|
105
|
+
* word, never on a dangling one.
|
|
106
|
+
*/
|
|
107
|
+
export declare function aiSiteSeoDescription(input: {
|
|
108
|
+
name: string;
|
|
109
|
+
subject: string;
|
|
110
|
+
about: string;
|
|
111
|
+
audience: string;
|
|
112
|
+
}): string;
|
|
80
113
|
/**
|
|
81
114
|
* What the listing is built from: either answer may be missing, because the
|
|
82
115
|
* guided start requires only the first of them.
|
|
@@ -84,6 +117,8 @@ export declare const AI_SITE_SEO_NOTE = "Written from what you said the site is
|
|
|
84
117
|
export interface AiSiteSeoInput extends Partial<AiSiteWords> {
|
|
85
118
|
/** The site's own name where the job carries one; empty for a site named on its pages. */
|
|
86
119
|
siteName?: string;
|
|
120
|
+
/** The city the person gave, where they gave one. */
|
|
121
|
+
city?: string;
|
|
87
122
|
}
|
|
88
123
|
/**
|
|
89
124
|
* The site's fallback listing from the answers, or `null` when the answers
|
|
@@ -63,12 +63,139 @@ import { aiSiteWords } from "./ai-site-job.js";
|
|
|
63
63
|
/** The kind an `seo` output carries when it is a guided start's site listing. */ export const AI_SITE_SEO_PROPOSAL_KIND = 'site-listing';
|
|
64
64
|
/** The id the scaffold reports its one site listing under. */ export const AI_SITE_SEO_OUTPUT_ID = 'site:listing';
|
|
65
65
|
/** What the proposal says about itself wherever it is shown. */ export const AI_SITE_SEO_NOTE = 'Written from what you said the site is and who it is for, not from its pages — read it before you save it.';
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Words a phrase never ends on (AGL-3596): a title cut after "for" or "that"
|
|
68
|
+
* reads as broken mid-sentence, which a search result shows to everybody.
|
|
69
|
+
*/ const DANGLING = new Set([
|
|
70
|
+
'a',
|
|
71
|
+
'an',
|
|
72
|
+
'and',
|
|
73
|
+
'as',
|
|
74
|
+
'at',
|
|
75
|
+
'by',
|
|
76
|
+
'for',
|
|
77
|
+
'from',
|
|
78
|
+
'in',
|
|
79
|
+
'into',
|
|
80
|
+
'of',
|
|
81
|
+
'on',
|
|
82
|
+
'or',
|
|
83
|
+
'so',
|
|
84
|
+
'that',
|
|
85
|
+
'the',
|
|
86
|
+
'to',
|
|
87
|
+
'which',
|
|
88
|
+
'who',
|
|
89
|
+
'with',
|
|
90
|
+
'where',
|
|
91
|
+
'whose',
|
|
92
|
+
'while',
|
|
93
|
+
'your',
|
|
94
|
+
'our',
|
|
95
|
+
'—',
|
|
96
|
+
'-',
|
|
97
|
+
'–',
|
|
98
|
+
'&'
|
|
99
|
+
]);
|
|
100
|
+
/** Without the words a phrase never ends on, or the punctuation before them. */ function withoutDangling(text) {
|
|
101
|
+
const words = text.split(' ');
|
|
102
|
+
while(words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase()))words.pop();
|
|
103
|
+
return words.join(' ').replace(/[\s,;:—–-]+$/, '').trim();
|
|
104
|
+
}
|
|
105
|
+
/** Held to a length, cut at a word rather than mid-word, and never on a dangling word. */ function clip(value, max) {
|
|
67
106
|
const text = value.replace(/\s+/g, ' ').trim();
|
|
68
107
|
if (text.length <= max) return text;
|
|
69
108
|
const cut = text.slice(0, max + 1);
|
|
70
109
|
const space = cut.lastIndexOf(' ');
|
|
71
|
-
return (space > max / 2 ? cut.slice(0, space) : text.slice(0, max)).trim();
|
|
110
|
+
return withoutDangling((space > max / 2 ? cut.slice(0, space) : text.slice(0, max)).trim());
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Where the first clause of an answer ends: "a dog groomer in Austin THAT
|
|
114
|
+
* takes bookings for…" is a subject and then a sentence about it. A title
|
|
115
|
+
* keeps the subject.
|
|
116
|
+
*/ const CLAUSE_BOUNDARY = /\s+(?:that|which|who|whose|where|offering|providing|specializing|serving|helping|so|because|with|for|to|by|and|but)\b|[,;:(—–]|\s-\s/i;
|
|
117
|
+
/**
|
|
118
|
+
* A short search title (AGL-3596): the business's name, what it is and where,
|
|
119
|
+
* held to the title cap and never cut mid-phrase. The answer to "what kind of
|
|
120
|
+
* site" is often a whole sentence, and cutting that sentence at sixty
|
|
121
|
+
* characters published "…that takes bookings for" as a site's title.
|
|
122
|
+
*
|
|
123
|
+
* The longest of these that fits wins: the whole answer; its first clause
|
|
124
|
+
* (the subject before "that", "for", a comma…); that clause without its
|
|
125
|
+
* place. The city the person gave is added where the words do not already
|
|
126
|
+
* say it, and the name leads where there is one. Only when even the shortest
|
|
127
|
+
* does not fit is it cut, at a word, never on a dangling one.
|
|
128
|
+
*/ export function aiSiteSeoTitle(input) {
|
|
129
|
+
var _input_name, _input_city;
|
|
130
|
+
const max = AI_SITE_SEO_LIMITS.title;
|
|
131
|
+
const name = ((_input_name = input.name) != null ? _input_name : '').replace(/\s+/g, ' ').trim();
|
|
132
|
+
const subject = withoutDangling(input.subject.replace(/\s+/g, ' ').trim());
|
|
133
|
+
const city = ((_input_city = input.city) != null ? _input_city : '').replace(/\s+/g, ' ').trim();
|
|
134
|
+
const placed = (what)=>city && !what.toLowerCase().includes(city.toLowerCase()) ? `${what} in ${city}` : what;
|
|
135
|
+
const boundary = subject.search(CLAUSE_BOUNDARY);
|
|
136
|
+
const clause = withoutDangling(boundary > 0 ? subject.slice(0, boundary) : subject);
|
|
137
|
+
const bare = withoutDangling(clause.replace(/\s+in\s+[^]*$/i, '')) || clause;
|
|
138
|
+
const whats = [
|
|
139
|
+
...new Set([
|
|
140
|
+
placed(subject),
|
|
141
|
+
subject,
|
|
142
|
+
placed(clause),
|
|
143
|
+
clause,
|
|
144
|
+
bare
|
|
145
|
+
])
|
|
146
|
+
];
|
|
147
|
+
const candidates = name ? [
|
|
148
|
+
...whats.map((what)=>`${name} — ${what}`),
|
|
149
|
+
name
|
|
150
|
+
] : whats;
|
|
151
|
+
const fits = candidates.find((candidate)=>candidate.length <= max);
|
|
152
|
+
return fits != null ? fits : clip(candidates[candidates.length - 1], max);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Where a phrase may be cut without leaving half a thought (AGL-3596): before
|
|
156
|
+
* a relative clause, or before a "with" or "for" phrase, or at a semicolon or
|
|
157
|
+
* dash. A bare comma is not one — "a calm, gentle groom" cut at its comma
|
|
158
|
+
* ends on an adjective — and neither is "and", which joins a list.
|
|
159
|
+
*/ const PHRASE_BOUNDARY = /\s+(?:that|which|who|whose|where|while|because|with|for|offering|providing|serving)\b|[;(—–]|\s-\s/gi;
|
|
160
|
+
/** A phrase and its shorter forms, longest first: itself, then cut before each boundary, back to front. */ function phraseForms(text) {
|
|
161
|
+
const forms = [
|
|
162
|
+
text
|
|
163
|
+
];
|
|
164
|
+
const cuts = [
|
|
165
|
+
...text.matchAll(PHRASE_BOUNDARY)
|
|
166
|
+
].map((match)=>{
|
|
167
|
+
var _match_index;
|
|
168
|
+
return (_match_index = match.index) != null ? _match_index : -1;
|
|
169
|
+
}).filter((at)=>at > 0);
|
|
170
|
+
for (const at of cuts.reverse()){
|
|
171
|
+
const form = withoutDangling(text.slice(0, at).trim());
|
|
172
|
+
if (form && !forms.includes(form)) forms.push(form);
|
|
173
|
+
}
|
|
174
|
+
return forms;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The site's search description (AGL-3596): what the site is, then who it is
|
|
178
|
+
* for, composed WITHIN the description cap at phrase boundaries rather than
|
|
179
|
+
* cut at it. Cut at 155 characters, "…for dog owners in Hillside and nearby
|
|
180
|
+
* towns who want a calm, gentle" went out as a site's description. So the
|
|
181
|
+
* longest whole composition that fits wins: all of both; the audience without
|
|
182
|
+
* its trailing clauses; what the site is alone; then what the site is, cut
|
|
183
|
+
* back the same way. Only when not even the subject fits is it clipped at a
|
|
184
|
+
* word, never on a dangling one.
|
|
185
|
+
*/ export function aiSiteSeoDescription(input) {
|
|
186
|
+
const max = AI_SITE_SEO_LIMITS.description;
|
|
187
|
+
const lead = (what)=>input.name ? `${input.name} is ${what}` : asSubject(what);
|
|
188
|
+
const abouts = phraseForms(withoutDangling(input.about.replace(/[.!?]+$/, '')));
|
|
189
|
+
const audiences = input.audience ? phraseForms(withoutDangling(input.audience.replace(/[.!?]+$/, ''))) : [];
|
|
190
|
+
for (const about of abouts){
|
|
191
|
+
for (const audience of audiences){
|
|
192
|
+
const text = sentence(`${lead(about)}, for ${audience}`);
|
|
193
|
+
if (text.length <= max) return text;
|
|
194
|
+
}
|
|
195
|
+
const text = sentence(lead(about));
|
|
196
|
+
if (text.length <= max) return text;
|
|
197
|
+
}
|
|
198
|
+
return clip(sentence(lead(abouts[abouts.length - 1] || input.subject)), max);
|
|
72
199
|
}
|
|
73
200
|
/**
|
|
74
201
|
* The leading article dropped and the first letter raised: "a neighborhood
|
|
@@ -92,15 +219,23 @@ import { aiSiteWords } from "./ai-site-job.js";
|
|
|
92
219
|
* describe no site at all — a job with no `businessType` has nothing to
|
|
93
220
|
* propose, and an empty proposal is worse than none.
|
|
94
221
|
*/ export function aiSiteSeoProposal(input) {
|
|
95
|
-
var _input_about, _input_siteName, _input_audience;
|
|
96
|
-
var _input_about1;
|
|
222
|
+
var _input_about, _input_siteName, _input_audience, _input_about1;
|
|
97
223
|
const subject = asSubject((_input_about = input.about) != null ? _input_about : '');
|
|
98
224
|
if (!subject) return null;
|
|
99
225
|
const name = ((_input_siteName = input.siteName) != null ? _input_siteName : '').replace(/\s+/g, ' ').trim();
|
|
100
226
|
const audience = ((_input_audience = input.audience) != null ? _input_audience : '').replace(/\s+/g, ' ').trim();
|
|
101
|
-
const title =
|
|
102
|
-
|
|
103
|
-
|
|
227
|
+
const title = aiSiteSeoTitle({
|
|
228
|
+
name,
|
|
229
|
+
subject,
|
|
230
|
+
city: input.city
|
|
231
|
+
});
|
|
232
|
+
const about = ((_input_about1 = input.about) != null ? _input_about1 : '').replace(/\s+/g, ' ').trim();
|
|
233
|
+
const description = aiSiteSeoDescription({
|
|
234
|
+
name,
|
|
235
|
+
subject,
|
|
236
|
+
about,
|
|
237
|
+
audience
|
|
238
|
+
});
|
|
104
239
|
return {
|
|
105
240
|
kind: AI_SITE_SEO_PROPOSAL_KIND,
|
|
106
241
|
values: {
|
|
@@ -115,8 +250,11 @@ import { aiSiteWords } from "./ai-site-job.js";
|
|
|
115
250
|
/** The proposal a job's inputs imply, for the step that reports it. */ export function aiSiteSeoProposalForInputs(inputs) {
|
|
116
251
|
const words = aiSiteWords(inputs);
|
|
117
252
|
const businessName = inputs == null ? void 0 : inputs['businessName'];
|
|
253
|
+
const city = inputs == null ? void 0 : inputs['city'];
|
|
118
254
|
return aiSiteSeoProposal(_extends({}, words, typeof businessName === 'string' ? {
|
|
119
255
|
siteName: businessName
|
|
256
|
+
} : {}, typeof city === 'string' ? {
|
|
257
|
+
city
|
|
120
258
|
} : {}));
|
|
121
259
|
}
|
|
122
260
|
/** Whether a value is a record the reader may look inside. */ function isRecord(value) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start-seo.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { SCREEN_SEO_TEXT_GUIDANCE } from '@aglyn/aglyn/app-utils/screen-seo-fields'\nimport type { AiJobOutput } from './ai-jobs.types'\nimport { aiSiteWords, type AiSiteWords } from './ai-site-job'\n\n/**\n * The site's own search listing, from the answers (AGL-2918).\n *\n * A site has two SEO title-and-description pairs. A page's is the page's, and\n * the scaffold's page step writes one for each page it builds. The SITE's is\n * the fallback every page with none of its own publishes, and it is the one\n * nothing was filling: a newly created site carries whatever its creation\n * seeded, which is its subdomain or its display name, and a scaffolded site\n * would publish pages listed under that.\n *\n * ── Derived, not generated ───────────────────────────────────────────────\n *\n * The two values here are arithmetic on what the person typed, not a model's\n * writing, and that is deliberate on three counts. The answers are already\n * the sentence the fallback wants — \"what kind of site\" IS what the site is,\n * and \"who it is for\" IS who it is for. It costs nothing, on a job that is\n * already tens of model calls. And it is available on the scaffold's first\n * pass, so the proposal is waiting while the pages are still building rather\n * than arriving after them.\n *\n * ── A proposal, never a write ────────────────────────────────────────────\n *\n * Nothing here writes the site's settings. The values travel as a job output\n * and land in the site SEO form as unsaved edits through the `hostSeo` zone's\n * own `proposeDraft`; the form's Update is what stores them, and a person who\n * never opens that form is left with exactly what they had.\n *\n * Pure: strings in, strings out, no Firestore and no React.\n */\n\n/**\n * The site SEO form's field names, which are what a proposal is keyed by.\n *\n * The same two strings the host settings SEO section names its Title and\n * Description inputs. They are stated here rather than imported because the\n * form is the console's and this is a plugin: the zone's `proposeDraft`\n * ignores a key the form does not edit, so a name that drifted would stage\n * nothing rather than stage something wrong.\n */\nexport const AI_SITE_SEO_FORM_FIELDS = {\n title: 'seo.title',\n description: 'seo.description',\n} as const\n\nexport type AiSiteSeoFormField =\n (typeof AI_SITE_SEO_FORM_FIELDS)[keyof typeof AI_SITE_SEO_FORM_FIELDS]\n\n/**\n * What each value is held to: the lengths the SEO editors guide a listing to,\n * which are the same two the host settings form counts down from.\n */\nexport const AI_SITE_SEO_LIMITS = SCREEN_SEO_TEXT_GUIDANCE\n\n/** The kind an `seo` output carries when it is a guided start's site listing. */\nexport const AI_SITE_SEO_PROPOSAL_KIND = 'site-listing'\n\n/** The id the scaffold reports its one site listing under. */\nexport const AI_SITE_SEO_OUTPUT_ID = 'site:listing'\n\nexport interface AiSiteSeoProposal {\n kind: typeof AI_SITE_SEO_PROPOSAL_KIND\n /** Values for the site SEO form, by field name. */\n values: Record<AiSiteSeoFormField, string>\n /** Customer-safe lines about where the words came from. */\n notes: string[]\n}\n\n/** What the proposal says about itself wherever it is shown. */\nexport const AI_SITE_SEO_NOTE =\n 'Written from what you said the site is and who it is for, not from its pages — read it before you save it.'\n\n/** Held to a length, cut at a word rather than mid-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 (space > max / 2 ? cut.slice(0, space) : text.slice(0, max)).trim()\n}\n\n/**\n * The leading article dropped and the first letter raised: \"a neighborhood\n * dog groomer\" is how a person answers the question and is not how a title\n * starts. Anything the person already capitalized is left alone, so a name\n * typed as the site type survives.\n */\nfunction asSubject(about: string): string {\n // `\\b` rather than `\\s+`, so an answer that is ONLY an article leaves\n // nothing behind and the caller proposes nothing: a one-letter title is a\n // worse thing to stage into a required field than no proposal at all.\n const text = about.replace(/\\s+/g, ' ').trim().replace(/^(?:an?|the)\\b\\s*/i, '')\n if (!text) return ''\n return text[0].toUpperCase() + text.slice(1)\n}\n\n/** A sentence closed with one period, and never two. */\nfunction sentence(text: string): string {\n const trimmed = text.trim()\n return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`\n}\n\n/**\n * What the listing is built from: either answer may be missing, because the\n * guided start requires only the first of them.\n */\nexport interface AiSiteSeoInput extends Partial<AiSiteWords> {\n /** The site's own name where the job carries one; empty for a site named on its pages. */\n siteName?: string\n}\n\n/**\n * The site's fallback listing from the answers, or `null` when the answers\n * describe no site at all — a job with no `businessType` has nothing to\n * propose, and an empty proposal is worse than none.\n */\nexport function aiSiteSeoProposal(input: AiSiteSeoInput): AiSiteSeoProposal | null {\n const subject = asSubject(input.about ?? '')\n if (!subject) return null\n const name = (input.siteName ?? '').replace(/\\s+/g, ' ').trim()\n const audience = (input.audience ?? '').replace(/\\s+/g, ' ').trim()\n const title = clip(name ? `${name} — ${subject}` : subject, AI_SITE_SEO_LIMITS.title)\n const described = name ? `${name} is ${input.about?.trim()}` : subject\n const description = clip(\n sentence(audience ? `${described}, for ${audience}` : described),\n AI_SITE_SEO_LIMITS.description,\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: [AI_SITE_SEO_NOTE],\n }\n}\n\n/** The proposal a job's inputs imply, for the step that reports it. */\nexport function aiSiteSeoProposalForInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n): AiSiteSeoProposal | null {\n const words = aiSiteWords(inputs)\n const businessName = inputs?.['businessName']\n return aiSiteSeoProposal({\n ...words,\n ...(typeof businessName === 'string' ? { siteName: businessName } : {}),\n })\n}\n\n/** Whether a value is a record the reader may look inside. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The site listing a job's outputs carry, or `null` when they carry none.\n *\n * Read defensively: an output is whatever a past release wrote, so a proposal\n * whose values are not two strings is treated as no proposal rather than\n * staged into somebody's live SEO form.\n */\nexport function aiSiteSeoProposalOf(\n outputs: readonly Pick<AiJobOutput, 'resource' | 'proposal'>[] | null | undefined,\n): AiSiteSeoProposal | null {\n for (const output of outputs ?? []) {\n if (output.resource !== 'seo') continue\n const proposal = output.proposal\n if (!isRecord(proposal) || proposal['kind'] !== AI_SITE_SEO_PROPOSAL_KIND) continue\n const values = isRecord(proposal['values']) ? proposal['values'] : {}\n const title = values[AI_SITE_SEO_FORM_FIELDS.title]\n const description = values[AI_SITE_SEO_FORM_FIELDS.description]\n if (typeof title !== 'string' || typeof description !== 'string') continue\n if (!title.trim() || !description.trim()) continue\n const notes = Array.isArray(proposal['notes'])\n ? proposal['notes'].filter((note): note is string => typeof note === 'string')\n : []\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes,\n }\n }\n return null\n}\n"],"names":["SCREEN_SEO_TEXT_GUIDANCE","aiSiteWords","AI_SITE_SEO_FORM_FIELDS","title","description","AI_SITE_SEO_LIMITS","AI_SITE_SEO_PROPOSAL_KIND","AI_SITE_SEO_OUTPUT_ID","AI_SITE_SEO_NOTE","clip","value","max","text","replace","trim","length","cut","slice","space","lastIndexOf","asSubject","about","toUpperCase","sentence","trimmed","test","aiSiteSeoProposal","input","subject","name","siteName","audience","described","kind","values","notes","aiSiteSeoProposalForInputs","inputs","words","businessName","isRecord","Array","isArray","aiSiteSeoProposalOf","outputs","output","resource","proposal","filter","note"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-site-start-seo.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { SCREEN_SEO_TEXT_GUIDANCE } from '@aglyn/aglyn/app-utils/screen-seo-fields'\nimport type { AiJobOutput } from './ai-jobs.types'\nimport { aiSiteWords, type AiSiteWords } from './ai-site-job'\n\n/**\n * The site's own search listing, from the answers (AGL-2918).\n *\n * A site has two SEO title-and-description pairs. A page's is the page's, and\n * the scaffold's page step writes one for each page it builds. The SITE's is\n * the fallback every page with none of its own publishes, and it is the one\n * nothing was filling: a newly created site carries whatever its creation\n * seeded, which is its subdomain or its display name, and a scaffolded site\n * would publish pages listed under that.\n *\n * ── Derived, not generated ───────────────────────────────────────────────\n *\n * The two values here are arithmetic on what the person typed, not a model's\n * writing, and that is deliberate on three counts. The answers are already\n * the sentence the fallback wants — \"what kind of site\" IS what the site is,\n * and \"who it is for\" IS who it is for. It costs nothing, on a job that is\n * already tens of model calls. And it is available on the scaffold's first\n * pass, so the proposal is waiting while the pages are still building rather\n * than arriving after them.\n *\n * ── A proposal, never a write ────────────────────────────────────────────\n *\n * Nothing here writes the site's settings. The values travel as a job output\n * and land in the site SEO form as unsaved edits through the `hostSeo` zone's\n * own `proposeDraft`; the form's Update is what stores them, and a person who\n * never opens that form is left with exactly what they had.\n *\n * Pure: strings in, strings out, no Firestore and no React.\n */\n\n/**\n * The site SEO form's field names, which are what a proposal is keyed by.\n *\n * The same two strings the host settings SEO section names its Title and\n * Description inputs. They are stated here rather than imported because the\n * form is the console's and this is a plugin: the zone's `proposeDraft`\n * ignores a key the form does not edit, so a name that drifted would stage\n * nothing rather than stage something wrong.\n */\nexport const AI_SITE_SEO_FORM_FIELDS = {\n title: 'seo.title',\n description: 'seo.description',\n} as const\n\nexport type AiSiteSeoFormField =\n (typeof AI_SITE_SEO_FORM_FIELDS)[keyof typeof AI_SITE_SEO_FORM_FIELDS]\n\n/**\n * What each value is held to: the lengths the SEO editors guide a listing to,\n * which are the same two the host settings form counts down from.\n */\nexport const AI_SITE_SEO_LIMITS = SCREEN_SEO_TEXT_GUIDANCE\n\n/** The kind an `seo` output carries when it is a guided start's site listing. */\nexport const AI_SITE_SEO_PROPOSAL_KIND = 'site-listing'\n\n/** The id the scaffold reports its one site listing under. */\nexport const AI_SITE_SEO_OUTPUT_ID = 'site:listing'\n\nexport interface AiSiteSeoProposal {\n kind: typeof AI_SITE_SEO_PROPOSAL_KIND\n /** Values for the site SEO form, by field name. */\n values: Record<AiSiteSeoFormField, string>\n /** Customer-safe lines about where the words came from. */\n notes: string[]\n}\n\n/** What the proposal says about itself wherever it is shown. */\nexport const AI_SITE_SEO_NOTE =\n 'Written from what you said the site is and who it is for, not from its pages — read it before you save it.'\n\n/**\n * Words a phrase never ends on (AGL-3596): a title cut after \"for\" or \"that\"\n * reads as broken mid-sentence, which a search result shows to everybody.\n */\nconst DANGLING = new Set([\n 'a', 'an', 'and', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'so',\n 'that', 'the', 'to', 'which', 'who', 'with', 'where', 'whose', 'while', 'your', 'our', '—', '-', '–', '&',\n])\n\n/** Without the words a phrase never ends on, or the punctuation before them. */\nfunction withoutDangling(text: string): string {\n const words = text.split(' ')\n while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop()\n return words.join(' ').replace(/[\\s,;:—–-]+$/, '').trim()\n}\n\n/** Held to a length, cut at a word rather than mid-word, and never on a dangling word. */\nfunction clip(value: string, max: number): string {\n const text = value.replace(/\\s+/g, ' ').trim()\n if (text.length <= max) return text\n const cut = text.slice(0, max + 1)\n const space = cut.lastIndexOf(' ')\n return withoutDangling((space > max / 2 ? cut.slice(0, space) : text.slice(0, max)).trim())\n}\n\n/**\n * Where the first clause of an answer ends: \"a dog groomer in Austin THAT\n * takes bookings for…\" is a subject and then a sentence about it. A title\n * keeps the subject.\n */\nconst CLAUSE_BOUNDARY =\n /\\s+(?:that|which|who|whose|where|offering|providing|specializing|serving|helping|so|because|with|for|to|by|and|but)\\b|[,;:(—–]|\\s-\\s/i\n\n/**\n * A short search title (AGL-3596): the business's name, what it is and where,\n * held to the title cap and never cut mid-phrase. The answer to \"what kind of\n * site\" is often a whole sentence, and cutting that sentence at sixty\n * characters published \"…that takes bookings for\" as a site's title.\n *\n * The longest of these that fits wins: the whole answer; its first clause\n * (the subject before \"that\", \"for\", a comma…); that clause without its\n * place. The city the person gave is added where the words do not already\n * say it, and the name leads where there is one. Only when even the shortest\n * does not fit is it cut, at a word, never on a dangling one.\n */\nexport function aiSiteSeoTitle(input: { name?: string; subject: string; city?: string }): string {\n const max = AI_SITE_SEO_LIMITS.title\n const name = (input.name ?? '').replace(/\\s+/g, ' ').trim()\n const subject = withoutDangling(input.subject.replace(/\\s+/g, ' ').trim())\n const city = (input.city ?? '').replace(/\\s+/g, ' ').trim()\n const placed = (what: string) =>\n city && !what.toLowerCase().includes(city.toLowerCase()) ? `${what} in ${city}` : what\n const boundary = subject.search(CLAUSE_BOUNDARY)\n const clause = withoutDangling(boundary > 0 ? subject.slice(0, boundary) : subject)\n const bare = withoutDangling(clause.replace(/\\s+in\\s+[^]*$/i, '')) || clause\n const whats = [...new Set([placed(subject), subject, placed(clause), clause, bare])]\n const candidates = name ? [...whats.map((what) => `${name} — ${what}`), name] : whats\n const fits = candidates.find((candidate) => candidate.length <= max)\n return fits ?? clip(candidates[candidates.length - 1], max)\n}\n\n/**\n * Where a phrase may be cut without leaving half a thought (AGL-3596): before\n * a relative clause, or before a \"with\" or \"for\" phrase, or at a semicolon or\n * dash. A bare comma is not one — \"a calm, gentle groom\" cut at its comma\n * ends on an adjective — and neither is \"and\", which joins a list.\n */\nconst PHRASE_BOUNDARY = /\\s+(?:that|which|who|whose|where|while|because|with|for|offering|providing|serving)\\b|[;(—–]|\\s-\\s/gi\n\n/** A phrase and its shorter forms, longest first: itself, then cut before each boundary, back to front. */\nfunction phraseForms(text: string): string[] {\n const forms = [text]\n const cuts = [...text.matchAll(PHRASE_BOUNDARY)].map((match) => match.index ?? -1).filter((at) => at > 0)\n for (const at of cuts.reverse()) {\n const form = withoutDangling(text.slice(0, at).trim())\n if (form && !forms.includes(form)) forms.push(form)\n }\n return forms\n}\n\n/**\n * The site's search description (AGL-3596): what the site is, then who it is\n * for, composed WITHIN the description cap at phrase boundaries rather than\n * cut at it. Cut at 155 characters, \"…for dog owners in Hillside and nearby\n * towns who want a calm, gentle\" went out as a site's description. So the\n * longest whole composition that fits wins: all of both; the audience without\n * its trailing clauses; what the site is alone; then what the site is, cut\n * back the same way. Only when not even the subject fits is it clipped at a\n * word, never on a dangling one.\n */\nexport function aiSiteSeoDescription(input: { name: string; subject: string; about: string; audience: string }): string {\n const max = AI_SITE_SEO_LIMITS.description\n const lead = (what: string) => (input.name ? `${input.name} is ${what}` : asSubject(what))\n const abouts = phraseForms(withoutDangling(input.about.replace(/[.!?]+$/, '')))\n const audiences = input.audience ? phraseForms(withoutDangling(input.audience.replace(/[.!?]+$/, ''))) : []\n for (const about of abouts) {\n for (const audience of audiences) {\n const text = sentence(`${lead(about)}, for ${audience}`)\n if (text.length <= max) return text\n }\n const text = sentence(lead(about))\n if (text.length <= max) return text\n }\n return clip(sentence(lead(abouts[abouts.length - 1] || input.subject)), max)\n}\n\n/**\n * The leading article dropped and the first letter raised: \"a neighborhood\n * dog groomer\" is how a person answers the question and is not how a title\n * starts. Anything the person already capitalized is left alone, so a name\n * typed as the site type survives.\n */\nfunction asSubject(about: string): string {\n // `\\b` rather than `\\s+`, so an answer that is ONLY an article leaves\n // nothing behind and the caller proposes nothing: a one-letter title is a\n // worse thing to stage into a required field than no proposal at all.\n const text = about.replace(/\\s+/g, ' ').trim().replace(/^(?:an?|the)\\b\\s*/i, '')\n if (!text) return ''\n return text[0].toUpperCase() + text.slice(1)\n}\n\n/** A sentence closed with one period, and never two. */\nfunction sentence(text: string): string {\n const trimmed = text.trim()\n return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`\n}\n\n/**\n * What the listing is built from: either answer may be missing, because the\n * guided start requires only the first of them.\n */\nexport interface AiSiteSeoInput extends Partial<AiSiteWords> {\n /** The site's own name where the job carries one; empty for a site named on its pages. */\n siteName?: string\n /** The city the person gave, where they gave one. */\n city?: string\n}\n\n/**\n * The site's fallback listing from the answers, or `null` when the answers\n * describe no site at all — a job with no `businessType` has nothing to\n * propose, and an empty proposal is worse than none.\n */\nexport function aiSiteSeoProposal(input: AiSiteSeoInput): AiSiteSeoProposal | null {\n const subject = asSubject(input.about ?? '')\n if (!subject) return null\n const name = (input.siteName ?? '').replace(/\\s+/g, ' ').trim()\n const audience = (input.audience ?? '').replace(/\\s+/g, ' ').trim()\n const title = aiSiteSeoTitle({ name, subject, city: input.city })\n const about = (input.about ?? '').replace(/\\s+/g, ' ').trim()\n const description = aiSiteSeoDescription({ name, subject, about, audience })\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes: [AI_SITE_SEO_NOTE],\n }\n}\n\n/** The proposal a job's inputs imply, for the step that reports it. */\nexport function aiSiteSeoProposalForInputs(\n inputs: Readonly<Record<string, unknown>> | null | undefined,\n): AiSiteSeoProposal | null {\n const words = aiSiteWords(inputs)\n const businessName = inputs?.['businessName']\n const city = inputs?.['city']\n return aiSiteSeoProposal({\n ...words,\n ...(typeof businessName === 'string' ? { siteName: businessName } : {}),\n ...(typeof city === 'string' ? { city } : {}),\n })\n}\n\n/** Whether a value is a record the reader may look inside. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The site listing a job's outputs carry, or `null` when they carry none.\n *\n * Read defensively: an output is whatever a past release wrote, so a proposal\n * whose values are not two strings is treated as no proposal rather than\n * staged into somebody's live SEO form.\n */\nexport function aiSiteSeoProposalOf(\n outputs: readonly Pick<AiJobOutput, 'resource' | 'proposal'>[] | null | undefined,\n): AiSiteSeoProposal | null {\n for (const output of outputs ?? []) {\n if (output.resource !== 'seo') continue\n const proposal = output.proposal\n if (!isRecord(proposal) || proposal['kind'] !== AI_SITE_SEO_PROPOSAL_KIND) continue\n const values = isRecord(proposal['values']) ? proposal['values'] : {}\n const title = values[AI_SITE_SEO_FORM_FIELDS.title]\n const description = values[AI_SITE_SEO_FORM_FIELDS.description]\n if (typeof title !== 'string' || typeof description !== 'string') continue\n if (!title.trim() || !description.trim()) continue\n const notes = Array.isArray(proposal['notes'])\n ? proposal['notes'].filter((note): note is string => typeof note === 'string')\n : []\n return {\n kind: AI_SITE_SEO_PROPOSAL_KIND,\n values: {\n [AI_SITE_SEO_FORM_FIELDS.title]: title,\n [AI_SITE_SEO_FORM_FIELDS.description]: description,\n },\n notes,\n }\n }\n return null\n}\n"],"names":["SCREEN_SEO_TEXT_GUIDANCE","aiSiteWords","AI_SITE_SEO_FORM_FIELDS","title","description","AI_SITE_SEO_LIMITS","AI_SITE_SEO_PROPOSAL_KIND","AI_SITE_SEO_OUTPUT_ID","AI_SITE_SEO_NOTE","DANGLING","Set","withoutDangling","text","words","split","length","has","toLowerCase","pop","join","replace","trim","clip","value","max","cut","slice","space","lastIndexOf","CLAUSE_BOUNDARY","aiSiteSeoTitle","input","name","subject","city","placed","what","includes","boundary","search","clause","bare","whats","candidates","map","fits","find","candidate","PHRASE_BOUNDARY","phraseForms","forms","cuts","matchAll","match","index","filter","at","reverse","form","push","aiSiteSeoDescription","lead","asSubject","abouts","about","audiences","audience","sentence","toUpperCase","trimmed","test","aiSiteSeoProposal","siteName","kind","values","notes","aiSiteSeoProposalForInputs","inputs","businessName","isRecord","Array","isArray","aiSiteSeoProposalOf","outputs","output","resource","proposal","note"],"mappings":"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"}
|
|
@@ -122,3 +122,17 @@ export declare function aiSiteStartBrief(answers: AiSiteStartAnswers): string;
|
|
|
122
122
|
* on the site already.
|
|
123
123
|
*/
|
|
124
124
|
export declare function aiSiteStartInputs(answers: AiSiteStartAnswers): Omit<AiSiteJobInputs, 'batchId'>;
|
|
125
|
+
/** The site inputs a guided start's answers become, by name: what a summary carries back (AGL-3596). */
|
|
126
|
+
export declare const AI_SITE_START_INPUT_KEYS: readonly ["businessType", "audience", "starter", "pages", "businessName", "city", "brand", "submissions", "welcomeEmail"];
|
|
127
|
+
/** A site job's scalar inputs, and nothing else its inputs carry; `null` when it has none. */
|
|
128
|
+
export declare function aiSiteStartInputsOf(inputs: Readonly<Record<string, unknown>> | null | undefined): Record<string, unknown> | null;
|
|
129
|
+
/**
|
|
130
|
+
* A failed start's answers, back as the questions hold them (AGL-3596), so
|
|
131
|
+
* Try again reopens the guided start filled in for the person to adjust:
|
|
132
|
+
* the inverse of {@link aiSiteStartInputs}. Read defensively — an answer the
|
|
133
|
+
* questions no longer offer goes back to its default — and held to the
|
|
134
|
+
* workspace's page band. `null` when the inputs say no kind of site at all.
|
|
135
|
+
*/
|
|
136
|
+
export declare function aiSiteStartAnswersFromInputs(inputs: Readonly<Record<string, unknown>> | null | undefined, options?: {
|
|
137
|
+
freeTaste?: boolean;
|
|
138
|
+
}): AiSiteStartAnswers | null;
|
|
@@ -157,5 +157,48 @@
|
|
|
157
157
|
welcomeEmail: answers.welcomeEmail
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
|
+
/** The site inputs a guided start's answers become, by name: what a summary carries back (AGL-3596). */ export const AI_SITE_START_INPUT_KEYS = [
|
|
161
|
+
'businessType',
|
|
162
|
+
'audience',
|
|
163
|
+
'starter',
|
|
164
|
+
'pages',
|
|
165
|
+
'businessName',
|
|
166
|
+
'city',
|
|
167
|
+
'brand',
|
|
168
|
+
'submissions',
|
|
169
|
+
'welcomeEmail'
|
|
170
|
+
];
|
|
171
|
+
/** A site job's scalar inputs, and nothing else its inputs carry; `null` when it has none. */ export function aiSiteStartInputsOf(inputs) {
|
|
172
|
+
if (!inputs) return null;
|
|
173
|
+
const kept = {};
|
|
174
|
+
for (const key of AI_SITE_START_INPUT_KEYS){
|
|
175
|
+
const value = inputs[key];
|
|
176
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') kept[key] = value;
|
|
177
|
+
}
|
|
178
|
+
return Object.keys(kept).length ? kept : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* A failed start's answers, back as the questions hold them (AGL-3596), so
|
|
182
|
+
* Try again reopens the guided start filled in for the person to adjust:
|
|
183
|
+
* the inverse of {@link aiSiteStartInputs}. Read defensively — an answer the
|
|
184
|
+
* questions no longer offer goes back to its default — and held to the
|
|
185
|
+
* workspace's page band. `null` when the inputs say no kind of site at all.
|
|
186
|
+
*/ export function aiSiteStartAnswersFromInputs(inputs, options = {}) {
|
|
187
|
+
const text = (key)=>typeof (inputs == null ? void 0 : inputs[key]) === 'string' ? inputs[key].trim() : '';
|
|
188
|
+
const siteType = text('businessType');
|
|
189
|
+
if (!siteType) return null;
|
|
190
|
+
const band = aiSitePagesBand(options.freeTaste);
|
|
191
|
+
const pages = typeof (inputs == null ? void 0 : inputs['pages']) === 'number' ? Math.round(inputs['pages']) : AI_SITE_START_ANSWERS.pages;
|
|
192
|
+
const starter = text('starter');
|
|
193
|
+
const submissions = text('submissions');
|
|
194
|
+
return {
|
|
195
|
+
siteType,
|
|
196
|
+
audience: text('audience'),
|
|
197
|
+
example: AI_SITE_START_EXAMPLES.some((entry)=>entry.id === starter) ? starter : null,
|
|
198
|
+
pages: Math.min(band.max, Math.max(band.min, pages)),
|
|
199
|
+
submissions: AI_SITE_SUBMISSIONS.includes(submissions) ? submissions : AI_SITE_START_ANSWERS.submissions,
|
|
200
|
+
welcomeEmail: options.freeTaste ? false : (inputs == null ? void 0 : inputs['welcomeEmail']) !== false
|
|
201
|
+
};
|
|
202
|
+
}
|
|
160
203
|
|
|
161
204
|
//# sourceMappingURL=ai-site-start.js.map
|