@aglyn/plugins-ai 1.0.0-beta.244 → 1.0.0-beta.246

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.js +3 -1
  3. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  4. package/src/lib/components/ai-job-links.js +7 -0
  5. package/src/lib/components/ai-job-links.js.map +1 -1
  6. package/src/lib/components/ai-product-zones.d.ts +3 -1
  7. package/src/lib/components/ai-product-zones.js.map +1 -1
  8. package/src/lib/components/ai-products-hub-card.component.js +1 -1
  9. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  10. package/src/lib/components/ai-products-proposals.component.js +17 -9
  11. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  12. package/src/lib/components/ai-site-start-card.component.js +6 -4
  13. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  14. package/src/lib/components/ai-upsell-dialog.component.js +1 -1
  15. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
  16. package/src/lib/jobs/ai-build-capabilities.js +162 -2
  17. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  18. package/src/lib/jobs/ai-job-build-step.d.ts +7 -0
  19. package/src/lib/jobs/ai-job-build-step.js +85 -16
  20. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  21. package/src/lib/jobs/ai-job-duplicate-step.d.ts +123 -0
  22. package/src/lib/jobs/ai-job-duplicate-step.js +269 -0
  23. package/src/lib/jobs/ai-job-duplicate-step.js.map +1 -0
  24. package/src/lib/jobs/ai-job-layout-language.js +13 -0
  25. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  26. package/src/lib/jobs/ai-job-navigation-step.d.ts +157 -0
  27. package/src/lib/jobs/ai-job-navigation-step.js +439 -0
  28. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -0
  29. package/src/lib/jobs/ai-job-page-language.d.ts +2 -1
  30. package/src/lib/jobs/ai-job-page-language.js +21 -5
  31. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  32. package/src/lib/jobs/ai-job-page-sections.js +1 -1
  33. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  34. package/src/lib/jobs/ai-job-plan-step.d.ts +4 -2
  35. package/src/lib/jobs/ai-job-plan-step.js +16 -7
  36. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-products-step.d.ts +2 -2
  38. package/src/lib/jobs/ai-job-products-step.js +2 -2
  39. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-settings-step.d.ts +272 -0
  41. package/src/lib/jobs/ai-job-settings-step.js +695 -0
  42. package/src/lib/jobs/ai-job-settings-step.js.map +1 -0
  43. package/src/lib/jobs/ai-job-site-content.d.ts +20 -6
  44. package/src/lib/jobs/ai-job-site-content.js +28 -12
  45. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  46. package/src/lib/jobs/ai-job-site-datasets.d.ts +2 -0
  47. package/src/lib/jobs/ai-job-site-datasets.js +10 -3
  48. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  49. package/src/lib/jobs/ai-job-site-step.d.ts +3 -1
  50. package/src/lib/jobs/ai-job-site-step.js +70 -10
  51. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  52. package/src/lib/jobs/ai-site-publish.d.ts +62 -4
  53. package/src/lib/jobs/ai-site-publish.js +203 -56
  54. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  55. package/src/lib/layout-language/ai-layout-compiler.d.ts +7 -4
  56. package/src/lib/layout-language/ai-layout-compiler.js +26 -25
  57. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  58. package/src/lib/layout-language/ai-layout-frame-hollows.d.ts +41 -0
  59. package/src/lib/layout-language/ai-layout-frame-hollows.js +330 -0
  60. package/src/lib/layout-language/ai-layout-frame-hollows.js.map +1 -0
  61. package/src/lib/layout-language/ai-layout-frame.d.ts +18 -1
  62. package/src/lib/layout-language/ai-layout-frame.js +83 -13
  63. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  64. package/src/lib/layout-language/ai-layout-listings.d.ts +35 -0
  65. package/src/lib/layout-language/ai-layout-listings.js +34 -0
  66. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  67. package/src/lib/layout-language/ai-layout-tree.d.ts +2 -0
  68. package/src/lib/layout-language/ai-layout-tree.js +17 -0
  69. package/src/lib/layout-language/ai-layout-tree.js.map +1 -1
  70. package/src/lib/model/ai-build-job.d.ts +23 -0
  71. package/src/lib/model/ai-build-job.js +102 -13
  72. package/src/lib/model/ai-build-job.js.map +1 -1
  73. package/src/lib/model/ai-build-plan.d.ts +25 -0
  74. package/src/lib/model/ai-build-plan.js +45 -1
  75. package/src/lib/model/ai-build-plan.js.map +1 -1
  76. package/src/lib/model/ai-credit-estimate.d.ts +52 -0
  77. package/src/lib/model/ai-credit-estimate.js +51 -0
  78. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  79. package/src/lib/model/ai-job-activity.js +4 -1
  80. package/src/lib/model/ai-job-activity.js.map +1 -1
  81. package/src/lib/model/ai-jobs.types.d.ts +10 -2
  82. package/src/lib/model/ai-jobs.types.js +4 -1
  83. package/src/lib/model/ai-jobs.types.js.map +1 -1
  84. package/src/lib/model/ai-layout-menus.d.ts +104 -0
  85. package/src/lib/model/ai-layout-menus.js +518 -0
  86. package/src/lib/model/ai-layout-menus.js.map +1 -0
  87. package/src/lib/model/ai-products.d.ts +31 -1
  88. package/src/lib/model/ai-products.js +34 -1
  89. package/src/lib/model/ai-products.js.map +1 -1
  90. package/src/lib/model/ai-site-inventory.d.ts +15 -0
  91. package/src/lib/model/ai-site-inventory.js.map +1 -1
  92. package/src/lib/model/ai-site-job.d.ts +10 -0
  93. package/src/lib/model/ai-site-job.js +36 -9
  94. package/src/lib/model/ai-site-job.js.map +1 -1
  95. package/src/lib/model/ai-site-store-pages.js +1 -1
  96. package/src/lib/model/ai-site-store-pages.js.map +1 -1
  97. package/src/lib/runtime/ai-doctrine-validators.d.ts +53 -0
  98. package/src/lib/runtime/ai-doctrine-validators.js +166 -25
  99. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  100. package/src/lib/runtime/ai-doctrine.js +7 -2
  101. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  102. package/src/lib/runtime/ai-palette.generated.js +1 -1
  103. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  104. package/src/lib/runtime/ai-products-generation.d.ts +4 -2
  105. package/src/lib/runtime/ai-products-generation.js +12 -6
  106. package/src/lib/runtime/ai-products-generation.js.map +1 -1
  107. package/src/lib/runtime/site-inventory.d.ts +12 -0
  108. package/src/lib/runtime/site-inventory.js +29 -1
  109. package/src/lib/runtime/site-inventory.js.map +1 -1
  110. package/src/lib/server.js +12 -0
  111. package/src/lib/server.js.map +1 -1
  112. package/src/lib/testing/stand-in-dataset-index.js +37 -3
  113. package/src/lib/testing/stand-in-dataset-index.js.map +1 -1
  114. package/src/lib/tools/ai-products-tool.d.ts +3 -1
  115. package/src/lib/tools/ai-products-tool.js +15 -5
  116. package/src/lib/tools/ai-products-tool.js.map +1 -1
@@ -0,0 +1,695 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { supportedTimeZones } from "@aglyn/aglyn/app-utils/collection-entry-date";
18
+ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
19
+ import { inheritedMediaAlt } from "@aglyn/aglyn/app-utils/media-metadata";
20
+ import { mediaNodeSrc } from "@aglyn/aglyn/app-utils/media-ref";
21
+ import { hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
22
+ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
23
+ import { SCREEN_ROOT_PATH } from "@aglyn/aglyn/app-utils/screen-route";
24
+ import { normalizeLocaleTag } from "@aglyn/aglyn/app-utils/seo-locale";
25
+ import { isSeoTitleVariable } from "@aglyn/aglyn/app-utils/seo-title-variables";
26
+ import { dropPluginSiteCache } from "@aglyn/aglyn/plugin-manager/plugin-site-cache";
27
+ import { syncHostProjectionForMembers } from "@aglyn/tenant-data-admin/server/host-memberships";
28
+ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
29
+ import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
30
+ import { FieldValue } from "firebase-admin/firestore";
31
+ import { registerAiJobAdmission } from "./ai-job-admission.js";
32
+ import { aiJobEditModel } from "./ai-job-edit-step.js";
33
+ import { aiLimitReview, aiUnspentOutcome } from "./ai-job-generation.js";
34
+ import { registerAiJobStep } from "./ai-jobs.js";
35
+ /**
36
+ * The `settings` step (AGL-3748): a change to what a member can change in the
37
+ * site's settings — its name, its search defaults, its favicon, logo and
38
+ * social image from the media library, its language, its time zone, its
39
+ * contact details and its consent banner's mode — as a first-class build
40
+ * operation, so "rename my site to Juniper Clay Studio", "make the site
41
+ * Spanish" or "set the SEO description" is one item on Assist's plan card.
42
+ *
43
+ * NO MODEL. The planner already wrote the values as the item's arguments;
44
+ * this checks each against the rule the console's own control holds it to
45
+ * and writes it, so it costs no credits.
46
+ *
47
+ * ── Only the console's own fields ────────────────────────────────────────
48
+ *
49
+ * Every field written is one a settings card or form writes, under the same
50
+ * name and to the same limit: `displayName` (Admin → General, 30), `seo.title`
51
+ * (60), `seo.description` (155), `seo.separator` (3), `seo.titlePattern`
52
+ * (120, its own variables only), `seo.entity.name`, `seo.entity.description`
53
+ * (300), `seo.entity.telephone`, `business.supportEmail` and
54
+ * `business.address` (the Business details card), `locales`/`defaultLocale`
55
+ * (the Languages card), `timeZone` (one of the zones the console offers),
56
+ * `consent.mode` (the consent banner card), and `seo.favicon`, `logoUrl` and
57
+ * `seo.image` (with its size and alt) written as the media pickers write
58
+ * them, through `mediaNodeSrc`.
59
+ *
60
+ * NEVER: the address (subdomain, domains), plugins, members and roles,
61
+ * billing, lockdown, deletion, search engine indexing, analytics and
62
+ * advertising ids, verification tokens, or turning the consent banner off.
63
+ * Those are not arguments, so the planner cannot ask for them.
64
+ *
65
+ * ── Live on confirm ──────────────────────────────────────────────────────
66
+ *
67
+ * A setting has no draft: the console saves it straight to the host
68
+ * document, and the tenant renders it from there. So does this, once the
69
+ * person confirms the plan (Zach, 2026-10-12: live on confirm). The write
70
+ * drops the site's cached pages as the console's door does
71
+ * (`host-document-writes.ts`), with a site-wide publish outbox entry in the
72
+ * same transaction for the drain to fire if the drop does not land.
73
+ *
74
+ * ── Who may ──────────────────────────────────────────────────────────────
75
+ *
76
+ * The Firestore rules' own for these keys (`canWriteHostContent` and
77
+ * `hostWritesFrozen`): a member with a write role on the site (admin, editor
78
+ * or author), while no lockdown freezes the site, its org or the platform.
79
+ * Asked at the door, again before the write, and the role once more inside
80
+ * the transaction that writes.
81
+ *
82
+ * ── Idempotent ───────────────────────────────────────────────────────────
83
+ *
84
+ * The transaction compares each value with what is stored and writes only
85
+ * what differs, so a pass asked again writes nothing and announces nothing.
86
+ */ export const AI_SETTINGS_KIND = 'settings';
87
+ /**
88
+ * ASSUMED: the least time the step needs — the site, the org, up to three
89
+ * media lookups in two libraries, and the one transaction that writes. No
90
+ * model, so it fits an inline door.
91
+ */ export const AI_JOB_SETTINGS_STEP_MINIMUM_MS = 5000;
92
+ export const AI_SETTINGS_NO_SITE_COPY = 'Open the site whose settings should change.';
93
+ export const AI_SETTINGS_ROLE_COPY = 'Changing the site’s settings needs the admin, editor or author role on this site.';
94
+ export const AI_SETTINGS_LOCKED_COPY = 'This site is not accepting changes right now.';
95
+ export const AI_SETTINGS_NOTHING_COPY = 'The request named no site setting this could change.';
96
+ /** Where a person finds what the op changed, as the build's notes name it. */ export const AI_SETTINGS_WHERE = 'the site’s Settings';
97
+ /** The output's fixed id: one site's settings, whatever changed. */ export const AI_SETTINGS_OUTPUT_ID = 'site';
98
+ /** The console's own limits, each from the control that edits the field. */ export const AI_SETTINGS_LIMITS = {
99
+ /** Admin → General, Display name. */ name: 30,
100
+ /** Setup → SEO, Title. */ seoTitle: 60,
101
+ /** Setup → SEO, Description. */ seoDescription: 155,
102
+ /** Setup → SEO, Separator. */ separator: 3,
103
+ /** Setup → SEO, Title pattern. */ titlePattern: 120,
104
+ /** Setup → SEO, the publisher's description. */ businessDescription: 300,
105
+ businessName: 100,
106
+ telephone: 40,
107
+ supportEmail: 254,
108
+ address: 200
109
+ };
110
+ /** The Languages card's own pattern for one language code. */ export const AI_SETTINGS_LOCALE_PATTERN = /^[a-z]{2}(-[A-Za-z]{2,4})?$/;
111
+ /** The consent banner card's two modes; turning the banner off is not offered. */ export const AI_SETTINGS_CONSENT_MODES = [
112
+ 'geo',
113
+ 'strict'
114
+ ];
115
+ /** The media a setting may name, and the field each is written to. */ export const AI_SETTINGS_MEDIA_ARGS = [
116
+ 'favicon',
117
+ 'logo',
118
+ 'socialImage'
119
+ ];
120
+ const MEDIA_ARG_NOUNS = {
121
+ favicon: 'favicon',
122
+ logo: 'logo',
123
+ socialImage: 'social image'
124
+ };
125
+ /** The item's arguments, as the planner fills them in. Every one optional. */ export const AI_SETTINGS_ARGS_SCHEMA = {
126
+ type: 'object',
127
+ properties: {
128
+ name: {
129
+ type: 'string',
130
+ description: 'The site’s name as the console lists it. Visitors see the search title, not this.',
131
+ maxLength: AI_SETTINGS_LIMITS.name
132
+ },
133
+ seoTitle: {
134
+ type: 'string',
135
+ description: 'The search title pages fall back to, usually the business’s name.',
136
+ maxLength: AI_SETTINGS_LIMITS.seoTitle
137
+ },
138
+ seoDescription: {
139
+ type: 'string',
140
+ description: 'The search description pages fall back to: one or two plain sentences.',
141
+ maxLength: AI_SETTINGS_LIMITS.seoDescription
142
+ },
143
+ separator: {
144
+ type: 'string',
145
+ description: 'The character between a page’s name and the site title, such as | or ·.',
146
+ maxLength: AI_SETTINGS_LIMITS.separator
147
+ },
148
+ titlePattern: {
149
+ type: 'string',
150
+ description: 'How an untitled page is titled, using only {{page.name}}, {{site.separator}} and {{site.name}}.',
151
+ maxLength: AI_SETTINGS_LIMITS.titlePattern
152
+ },
153
+ businessName: {
154
+ type: 'string',
155
+ description: 'The business’s name as search engines and AI assistants are told it.',
156
+ maxLength: AI_SETTINGS_LIMITS.businessName
157
+ },
158
+ businessDescription: {
159
+ type: 'string',
160
+ description: 'What the business is, in one sentence (its tagline), as search engines and AI assistants are told it.',
161
+ maxLength: AI_SETTINGS_LIMITS.businessDescription
162
+ },
163
+ telephone: {
164
+ type: 'string',
165
+ description: 'The business’s phone number, exactly as the person gave it.',
166
+ maxLength: AI_SETTINGS_LIMITS.telephone
167
+ },
168
+ supportEmail: {
169
+ type: 'string',
170
+ description: 'The contact email pages and emails show, exactly as the person gave it.',
171
+ maxLength: AI_SETTINGS_LIMITS.supportEmail
172
+ },
173
+ address: {
174
+ type: 'string',
175
+ description: 'The business’s address on one line, exactly as the person gave it.',
176
+ maxLength: AI_SETTINGS_LIMITS.address
177
+ },
178
+ language: {
179
+ type: 'string',
180
+ description: 'The language the site is in, as a code such as en, es, fr or pt-BR. It does not translate the pages.',
181
+ maxLength: 10
182
+ },
183
+ timeZone: {
184
+ type: 'string',
185
+ description: 'The site’s time zone as an IANA name, such as America/Chicago.',
186
+ maxLength: 64
187
+ },
188
+ consentMode: {
189
+ type: 'string',
190
+ description: 'The cookie banner’s mode: geo (asks only where the law requires) or strict (asks every visitor).',
191
+ enum: AI_SETTINGS_CONSENT_MODES
192
+ },
193
+ favicon: {
194
+ type: 'string',
195
+ description: 'An image already in the media library, by its id or exact file name, for the browser tab icon.',
196
+ maxLength: 200
197
+ },
198
+ logo: {
199
+ type: 'string',
200
+ description: 'An image already in the media library, by its id or exact file name, for the site’s logo.',
201
+ maxLength: 200
202
+ },
203
+ socialImage: {
204
+ type: 'string',
205
+ description: 'An image already in the media library, by its id or exact file name, for the card shown when a page is shared.',
206
+ maxLength: 200
207
+ }
208
+ },
209
+ additionalProperties: false
210
+ };
211
+ const text = (value)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
212
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
213
+ const TELEPHONE = /^[+()0-9][0-9 ()+.\-/]{4,38}[0-9)]$/;
214
+ const PATTERN_VARIABLE = /\{\{\s*([^}]*?)\s*\}\}/g;
215
+ /** Whether a title pattern names only the variables the renderer knows. */ export function aiSettingsTitlePatternOk(pattern) {
216
+ if (/[{}]/.test(pattern.replace(PATTERN_VARIABLE, ''))) return false;
217
+ return [
218
+ ...pattern.matchAll(PATTERN_VARIABLE)
219
+ ].every((match)=>isSeoTitleVariable(match[1]));
220
+ }
221
+ /** A language code, as the Languages card stores it, or `null`. */ export function aiSettingsLocale(value) {
222
+ const tag = normalizeLocaleTag(value);
223
+ return tag && AI_SETTINGS_LOCALE_PATTERN.test(tag) ? tag : null;
224
+ }
225
+ /** The item's arguments read: what may be written, and a sentence for each that may not. */ export function aiSettingsRequestOf(inputs) {
226
+ const request = {};
227
+ const problems = [];
228
+ const bounded = (key, noun)=>{
229
+ const value = text(inputs == null ? void 0 : inputs[key]);
230
+ if (!value) return;
231
+ if (value.length > AI_SETTINGS_LIMITS[key]) {
232
+ problems.push(`The ${noun} is longer than ${AI_SETTINGS_LIMITS[key]} characters.`);
233
+ return;
234
+ }
235
+ request[key] = value;
236
+ };
237
+ bounded('name', 'site name');
238
+ bounded('seoTitle', 'search title');
239
+ bounded('seoDescription', 'search description');
240
+ bounded('separator', 'title separator');
241
+ bounded('businessName', 'business name');
242
+ bounded('businessDescription', 'business description');
243
+ bounded('address', 'address');
244
+ bounded('titlePattern', 'title pattern');
245
+ if (request.titlePattern && !aiSettingsTitlePatternOk(request.titlePattern)) {
246
+ problems.push('The title pattern may use only {{page.name}}, {{site.separator}} and {{site.name}}.');
247
+ delete request.titlePattern;
248
+ }
249
+ bounded('telephone', 'phone number');
250
+ if (request.telephone && !TELEPHONE.test(request.telephone)) {
251
+ problems.push(`“${request.telephone}” is not a phone number.`);
252
+ delete request.telephone;
253
+ }
254
+ bounded('supportEmail', 'contact email');
255
+ if (request.supportEmail && !EMAIL.test(request.supportEmail)) {
256
+ problems.push(`“${request.supportEmail}” is not an email address.`);
257
+ delete request.supportEmail;
258
+ }
259
+ const language = text(inputs == null ? void 0 : inputs['language']);
260
+ if (language) {
261
+ const locale = aiSettingsLocale(language);
262
+ if (locale) request.language = locale;
263
+ else problems.push(`“${language}” is not a language code such as es or pt-BR.`);
264
+ }
265
+ const timeZone = text(inputs == null ? void 0 : inputs['timeZone']);
266
+ if (timeZone) {
267
+ if (supportedTimeZones().includes(timeZone)) request.timeZone = timeZone;
268
+ else problems.push(`“${timeZone}” is not a time zone the site settings offer.`);
269
+ }
270
+ const consentMode = text(inputs == null ? void 0 : inputs['consentMode']);
271
+ if (consentMode) {
272
+ if (AI_SETTINGS_CONSENT_MODES.includes(consentMode)) {
273
+ request.consentMode = consentMode;
274
+ } else problems.push('The cookie banner’s mode is geo or strict.');
275
+ }
276
+ for (const key of AI_SETTINGS_MEDIA_ARGS){
277
+ const value = text(inputs == null ? void 0 : inputs[key]);
278
+ if (value) request[key] = value.slice(0, 200);
279
+ }
280
+ return {
281
+ request,
282
+ problems
283
+ };
284
+ }
285
+ /** Whether a read request asks for anything at all. */ export function aiSettingsRequestEmpty(request) {
286
+ return Object.keys(request).length === 0;
287
+ }
288
+ function mediaRowOf(doc, org) {
289
+ var _doc_data, _data_fileName, _data_contentType;
290
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
291
+ const number = (value)=>typeof value === 'number' && value > 0 ? value : null;
292
+ return _extends({
293
+ id: doc.id,
294
+ fileName: String((_data_fileName = data['fileName']) != null ? _data_fileName : ''),
295
+ contentType: String((_data_contentType = data['contentType']) != null ? _data_contentType : ''),
296
+ url: typeof data['url'] === 'string' ? data['url'] : null,
297
+ cdnPath: typeof data['cdnPath'] === 'string' ? data['cdnPath'] : null,
298
+ contentHash: typeof data['contentHash'] === 'string' ? data['contentHash'] : null,
299
+ private: data['private'] === true,
300
+ deleted: data['deletedAt'] != null
301
+ }, org ? {
302
+ visibleTo: Array.isArray(data['visibleTo']) ? data['visibleTo'] : null
303
+ } : {}, {
304
+ width: number(data['width']),
305
+ height: number(data['height']),
306
+ alt: typeof data['alt'] === 'string' ? data['alt'] : ''
307
+ });
308
+ }
309
+ /**
310
+ * The rows a media name could be, from the site's own library and then its
311
+ * org's: the one whose id it is, else those whose file name it is.
312
+ */ export async function readAiSettingsMediaRows(firestore, input) {
313
+ const libraries = [
314
+ {
315
+ ref: firestore.collection('hosts').doc(input.hostId).collection('media'),
316
+ org: false
317
+ },
318
+ {
319
+ ref: firestore.collection('orgs').doc(input.orgId).collection('media'),
320
+ org: true
321
+ }
322
+ ];
323
+ const rows = [];
324
+ for (const library of libraries){
325
+ if (/^[A-Za-z0-9_-]{1,128}$/.test(input.source)) {
326
+ const byId = await library.ref.doc(input.source).get();
327
+ if (byId.exists) rows.push(mediaRowOf(byId, library.org));
328
+ }
329
+ const byName = await library.ref.where('fileName', '==', input.source).limit(5).get();
330
+ for (const doc of byName.docs)if (!rows.some((row)=>row.id === doc.id)) rows.push(mediaRowOf(doc, library.org));
331
+ }
332
+ return rows;
333
+ }
334
+ /**
335
+ * The image a setting names, chosen from the rows read: a public image of
336
+ * this site's library, or of its org's that this site may use, never deleted.
337
+ * Its id wins over a file name; two images of one name are refused in words.
338
+ */ export function aiSettingsMediaOf(arg, source, rows, hostId) {
339
+ const noun = MEDIA_ARG_NOUNS[arg];
340
+ const usable = rows.filter((row)=>!row.deleted && !row.private && row.contentType.startsWith('image/') && (row.visibleTo === undefined || visibleToHost(row.visibleTo, hostId)));
341
+ const byId = usable.find((row)=>row.id === source);
342
+ const named = usable.filter((row)=>row.fileName === source);
343
+ if (!byId && named.length > 1) return {
344
+ error: `The media library has more than one image called “${source}”; name the one for the ${noun}.`
345
+ };
346
+ const row = byId != null ? byId : named[0];
347
+ if (!row) return {
348
+ error: `The media library has no image “${source}” this site can use for the ${noun}.`
349
+ };
350
+ const src = mediaNodeSrc({
351
+ url: row.url,
352
+ cdnPath: row.cdnPath,
353
+ contentHash: row.contentHash,
354
+ private: row.private
355
+ });
356
+ if (!src) return {
357
+ error: `The image “${source}” has no address the site can show.`
358
+ };
359
+ return {
360
+ src,
361
+ width: row.width,
362
+ height: row.height,
363
+ alt: row.alt,
364
+ fileName: row.fileName || row.id
365
+ };
366
+ }
367
+ const getPath = (data, path)=>path.split('.').reduce((at, key)=>at && typeof at === 'object' ? at[key] : undefined, data);
368
+ const sameValue = (a, b)=>JSON.stringify(a != null ? a : null) === JSON.stringify(b != null ? b : null);
369
+ /**
370
+ * The language change, as the Languages card would save it: a site with no
371
+ * languages, or one, now publishes in this one; a multilingual site that
372
+ * lists it makes it the default. A multilingual site that does not list it
373
+ * is refused, because adding a language is the Languages card's decision.
374
+ */ export function aiSettingsLanguageChanges(host, language) {
375
+ const locales = Array.isArray(host.locales) ? host.locales.filter((one)=>typeof one === 'string') : [];
376
+ const said = `The site’s language is now ${language}.`;
377
+ if (locales.length > 1) {
378
+ if (!locales.includes(language)) {
379
+ return {
380
+ error: `This site publishes in ${locales.join(', ')}; add ${language} in Settings → Details → Languages first.`
381
+ };
382
+ }
383
+ return [
384
+ {
385
+ path: 'defaultLocale',
386
+ value: language,
387
+ said
388
+ }
389
+ ];
390
+ }
391
+ return [
392
+ {
393
+ path: 'locales',
394
+ value: [
395
+ language
396
+ ],
397
+ said
398
+ },
399
+ {
400
+ path: 'defaultLocale',
401
+ value: language,
402
+ said
403
+ }
404
+ ];
405
+ }
406
+ /** Every change the request asks of this host, before comparing with what is stored. */ export function aiSettingsChanges(host, request, media) {
407
+ const changes = [];
408
+ const problems = [];
409
+ const set = (path, value, said)=>changes.push({
410
+ path,
411
+ value,
412
+ said
413
+ });
414
+ if (request.name) set('displayName', request.name, `The site is now called “${request.name}”.`);
415
+ if (request.seoTitle) set('seo.title', request.seoTitle, `The search title is now “${request.seoTitle}”.`);
416
+ if (request.seoDescription) set('seo.description', request.seoDescription, 'The search description is set.');
417
+ if (request.separator) set('seo.separator', request.separator, `The title separator is now “${request.separator}”.`);
418
+ if (request.titlePattern) set('seo.titlePattern', request.titlePattern, `Untitled pages are now titled “${request.titlePattern}”.`);
419
+ if (request.businessName) set('seo.entity.name', request.businessName, `The business name is now “${request.businessName}”.`);
420
+ if (request.businessDescription) set('seo.entity.description', request.businessDescription, 'The business description is set.');
421
+ if (request.telephone) set('seo.entity.telephone', request.telephone, `The phone number is now ${request.telephone}.`);
422
+ if (request.supportEmail) set('business.supportEmail', request.supportEmail, `The contact email is now ${request.supportEmail}.`);
423
+ if (request.address) set('business.address', request.address, 'The address is set.');
424
+ if (request.timeZone) set('timeZone', request.timeZone, `The site’s time zone is now ${request.timeZone}.`);
425
+ if (request.consentMode) {
426
+ set('consent.mode', request.consentMode, request.consentMode === 'strict' ? 'The cookie banner now asks every visitor.' : 'The cookie banner now asks where the law requires.');
427
+ }
428
+ if (request.language) {
429
+ const language = aiSettingsLanguageChanges(host, request.language);
430
+ if ('error' in language) problems.push(language.error);
431
+ else changes.push(...language);
432
+ }
433
+ if (media.favicon) set('seo.favicon', media.favicon.src, `The favicon is now “${media.favicon.fileName}”.`);
434
+ if (media.logo) set('logoUrl', media.logo.src, `The logo is now “${media.logo.fileName}”.`);
435
+ if (media.socialImage) {
436
+ var _image_width, _image_height, _inheritedMediaAlt;
437
+ const image = media.socialImage;
438
+ const said = `The social image is now “${image.fileName}”.`;
439
+ set('seo.image', image.src, said);
440
+ // Its size and alt, as the Social image card copies them at pick time.
441
+ set('seo.imageWidth', (_image_width = image.width) != null ? _image_width : 0, said);
442
+ set('seo.imageHeight', (_image_height = image.height) != null ? _image_height : 0, said);
443
+ const savedAlt = getPath(host, 'seo.imageAlt');
444
+ set('seo.imageAlt', (_inheritedMediaAlt = inheritedMediaAlt({
445
+ placementAlt: savedAlt,
446
+ assetAlt: image.alt
447
+ })) != null ? _inheritedMediaAlt : typeof savedAlt === 'string' ? savedAlt : '', said);
448
+ }
449
+ return {
450
+ changes,
451
+ problems
452
+ };
453
+ }
454
+ /**
455
+ * The settings written, in one transaction that reads the host as it is
456
+ * stored NOW: the member still holds a write role, and only the values that
457
+ * differ from what is stored are written, with a site-wide publish outbox
458
+ * entry beside them so the live site's cached pages are dropped.
459
+ */ export async function writeAiSiteSettings(firestore, input) {
460
+ const hostRef = firestore.collection('hosts').doc(input.hostId);
461
+ return firestore.runTransaction(async (tx)=>{
462
+ var _host_get, _host_data;
463
+ const host = await tx.get(hostRef);
464
+ if (!host.exists) return {
465
+ ok: false,
466
+ error: 'Unknown site'
467
+ };
468
+ const roles = (_host_get = host.get('memberRoles')) != null ? _host_get : {};
469
+ if (!hostRoleCanWrite(roles[input.uid])) return {
470
+ ok: false,
471
+ error: AI_SETTINGS_ROLE_COPY
472
+ };
473
+ const data = (_host_data = host.data()) != null ? _host_data : {};
474
+ const { changes, problems } = aiSettingsChanges(data, input.request, input.media);
475
+ const changed = changes.filter((change)=>!sameValue(getPath(data, change.path), change.value));
476
+ const done = [
477
+ ...new Set(changes.map((change)=>change.said))
478
+ ];
479
+ if (!changed.length) return {
480
+ ok: true,
481
+ done,
482
+ notDone: problems,
483
+ changed: [],
484
+ outboxId: null
485
+ };
486
+ const updates = {
487
+ updatedAt: input.now
488
+ };
489
+ for (const change of changed)updates[change.path] = change.value;
490
+ tx.update(hostRef, updates);
491
+ // The announce, written down in the save's own transaction: a setting
492
+ // has no address, so it asks for the whole site, as the console's does.
493
+ const outboxRef = firestore.collection('publishOutbox').doc(createResourceUid());
494
+ tx.set(outboxRef, {
495
+ hostId: input.hostId,
496
+ paths: [
497
+ SCREEN_ROOT_PATH
498
+ ],
499
+ entireHost: true,
500
+ createdAt: FieldValue.serverTimestamp(),
501
+ attempts: 0
502
+ });
503
+ return {
504
+ ok: true,
505
+ done,
506
+ notDone: problems,
507
+ changed: changed.map((change)=>change.path),
508
+ outboxId: outboxRef.id
509
+ };
510
+ });
511
+ }
512
+ // ── The step ──────────────────────────────────────────────────────────────
513
+ /** Why this member may not change this site's settings now, or `null`. */ export async function aiSettingsAccessRefusal(input, deps = {}) {
514
+ var _input_host_get, _deps_lockdown;
515
+ if (!input.host.exists) return 'Unknown site';
516
+ const roles = (_input_host_get = input.host.get('memberRoles')) != null ? _input_host_get : {};
517
+ if (!hostRoleCanWrite(roles[input.uid])) return AI_SETTINGS_ROLE_COPY;
518
+ const locked = await ((_deps_lockdown = deps.lockdown) != null ? _deps_lockdown : getLockdownVerdict)({
519
+ staff: false,
520
+ uid: input.uid,
521
+ org: input.org,
522
+ host: input.host.data(),
523
+ intent: 'write'
524
+ });
525
+ return locked ? AI_SETTINGS_LOCKED_COPY : null;
526
+ }
527
+ /** The output's note: what changed, that it is live, and what was not done. */ export function aiSettingsOutputNote(done, notDone, language) {
528
+ // The build's own wording for an item with no draft state (AGL-3745).
529
+ const did = done.length ? `${done.join(' ')} Live now, in ${AI_SETTINGS_WHERE}.` : 'Nothing needed changing.';
530
+ const translate = language ? ' The pages keep the words they have: changing the language does not translate them.' : '';
531
+ const not = notDone.length ? ` Not done: ${notDone.join(' ')}` : '';
532
+ return `${did}${translate}${not}`.slice(0, 1000);
533
+ }
534
+ /** Each media argument resolved to its image, or a sentence for each that cannot be. */ async function mediaOf(firestore, input, readMedia) {
535
+ const media = {};
536
+ const problems = [];
537
+ for (const arg of AI_SETTINGS_MEDIA_ARGS){
538
+ const source = input.request[arg];
539
+ if (!source) continue;
540
+ const rows = await readMedia(firestore, {
541
+ hostId: input.hostId,
542
+ orgId: input.orgId,
543
+ source
544
+ });
545
+ const found = aiSettingsMediaOf(arg, source, rows, input.hostId);
546
+ if ('error' in found) problems.push(found.error);
547
+ else media[arg] = found;
548
+ }
549
+ return {
550
+ media,
551
+ problems
552
+ };
553
+ }
554
+ export function createAiJobSettingsStep(deps = {}) {
555
+ var _deps_readMedia, _deps_write, _deps_dropCache, _deps_syncMemberships;
556
+ const readMedia = (_deps_readMedia = deps.readMedia) != null ? _deps_readMedia : readAiSettingsMediaRows;
557
+ const write = (_deps_write = deps.write) != null ? _deps_write : writeAiSiteSettings;
558
+ const dropCache = (_deps_dropCache = deps.dropCache) != null ? _deps_dropCache : dropPluginSiteCache;
559
+ const syncMemberships = (_deps_syncMemberships = deps.syncMemberships) != null ? _deps_syncMemberships : syncHostProjectionForMembers;
560
+ return async ({ job, now, firestore, org, modelFor })=>{
561
+ var _ref, _data;
562
+ // No model is asked: the outcome records the edit step's at no cost.
563
+ const model = (_ref = modelFor == null ? void 0 : modelFor('job.edit')) != null ? _ref : aiJobEditModel();
564
+ const unspent = (failure)=>_extends({}, aiUnspentOutcome(model), {
565
+ failure
566
+ });
567
+ const hostId = job.hostId;
568
+ if (!hostId) return unspent(AI_SETTINGS_NO_SITE_COPY);
569
+ const read = aiSettingsRequestOf(job.inputs);
570
+ if (aiSettingsRequestEmpty(read.request)) return unspent([
571
+ AI_SETTINGS_NOTHING_COPY,
572
+ ...read.problems
573
+ ].join(' '));
574
+ const orgData = org != null ? org : (_data = (await firestore.collection('orgs').doc(job.orgId).get()).data()) != null ? _data : null;
575
+ const host = await firestore.collection('hosts').doc(hostId).get();
576
+ const refused = await aiSettingsAccessRefusal({
577
+ host,
578
+ uid: job.createdBy,
579
+ org: orgData
580
+ }, {
581
+ lockdown: deps.lockdown
582
+ });
583
+ if (refused) return aiUnspentOutcome(model, {
584
+ review: aiLimitReview(refused)
585
+ });
586
+ const found = await mediaOf(firestore, {
587
+ hostId,
588
+ orgId: job.orgId,
589
+ request: read.request
590
+ }, readMedia);
591
+ const written = await write(firestore, {
592
+ hostId,
593
+ uid: job.createdBy,
594
+ request: read.request,
595
+ media: found.media,
596
+ now
597
+ });
598
+ if (written.ok === false) return unspent(written.error);
599
+ const notDone = [
600
+ ...read.problems,
601
+ ...found.problems,
602
+ ...written.notDone
603
+ ];
604
+ if (!written.done.length) return unspent([
605
+ AI_SETTINGS_NOTHING_COPY,
606
+ ...notDone
607
+ ].join(' '));
608
+ if (written.outboxId) {
609
+ // The live site's cached pages go now; the outbox entry stays for the
610
+ // drain unless the drop certainly happened.
611
+ const dropped = await dropCache({
612
+ hostIds: [
613
+ hostId
614
+ ],
615
+ reason: 'AI build changed site settings'
616
+ }).catch(()=>({
617
+ complete: false
618
+ }));
619
+ if (dropped.complete) await firestore.collection('publishOutbox').doc(written.outboxId).delete().catch(()=>undefined);
620
+ }
621
+ if (written.changed.includes('displayName')) {
622
+ // The site switcher lists sites by the name members' projections carry.
623
+ await syncMemberships(job.orgId, hostId).catch((error)=>{
624
+ console.error('ai settings: membership projection not synced', {
625
+ hostId,
626
+ jobId: job.$id,
627
+ error
628
+ });
629
+ });
630
+ }
631
+ const output = {
632
+ resource: 'settings',
633
+ id: AI_SETTINGS_OUTPUT_ID,
634
+ hostId,
635
+ hostSubdomain: typeof host.get('subdomain') === 'string' && host.get('subdomain') || null,
636
+ label: 'Site settings',
637
+ note: aiSettingsOutputNote(written.done, notDone, Boolean(read.request.language))
638
+ };
639
+ return aiUnspentOutcome(model, {
640
+ outputs: [
641
+ output
642
+ ]
643
+ });
644
+ };
645
+ }
646
+ export const runAiJobSettingsStep = createAiJobSettingsStep();
647
+ /**
648
+ * A settings change is admitted on a site of the job's own org, for a member
649
+ * who may write its settings now, where the request names at least one
650
+ * setting this can change. Refused in those words before anything is written.
651
+ */ export function createAiSettingsJobAdmission(deps = {}) {
652
+ var _deps_ownerOf;
653
+ const ownerOf = (_deps_ownerOf = deps.ownerOf) != null ? _deps_ownerOf : resolveOrgIdForHost;
654
+ return async (context)=>{
655
+ var _context_uid;
656
+ if (!context.hostId) return {
657
+ status: 400,
658
+ error: AI_SETTINGS_NO_SITE_COPY
659
+ };
660
+ const owner = await ownerOf(context.hostId);
661
+ if (!owner || owner !== context.orgId) return {
662
+ status: 404,
663
+ error: 'Unknown site'
664
+ };
665
+ const host = await context.firestore.collection('hosts').doc(context.hostId).get();
666
+ const refused = await aiSettingsAccessRefusal({
667
+ host,
668
+ uid: (_context_uid = context.uid) != null ? _context_uid : '',
669
+ org: context.org
670
+ }, {
671
+ lockdown: deps.lockdown
672
+ });
673
+ if (refused) return {
674
+ status: 403,
675
+ error: refused
676
+ };
677
+ const read = aiSettingsRequestOf(context.inputs);
678
+ return aiSettingsRequestEmpty(read.request) ? {
679
+ status: 400,
680
+ error: [
681
+ AI_SETTINGS_NOTHING_COPY,
682
+ ...read.problems
683
+ ].join(' ')
684
+ } : null;
685
+ };
686
+ }
687
+ export const aiSettingsJobAdmission = createAiSettingsJobAdmission();
688
+ /** Registers the settings step and its door (AGL-3748). */ export function registerAiSettingsJob() {
689
+ registerAiJobStep(AI_SETTINGS_KIND, runAiJobSettingsStep, {
690
+ minimumMs: AI_JOB_SETTINGS_STEP_MINIMUM_MS
691
+ });
692
+ registerAiJobAdmission(AI_SETTINGS_KIND, aiSettingsJobAdmission);
693
+ }
694
+
695
+ //# sourceMappingURL=ai-job-settings-step.js.map