@aglyn/aglyn 1.0.0-beta.231 → 1.0.0-beta.232

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 (51) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/account-acquisition.d.ts +15 -2
  3. package/src/lib/app-utils/account-acquisition.js +20 -1
  4. package/src/lib/app-utils/account-acquisition.js.map +1 -1
  5. package/src/lib/app-utils/artifact-list-queries.d.ts +35 -0
  6. package/src/lib/app-utils/artifact-list-queries.js +203 -0
  7. package/src/lib/app-utils/artifact-list-queries.js.map +1 -0
  8. package/src/lib/app-utils/business-profile.d.ts +171 -0
  9. package/src/lib/app-utils/business-profile.js +277 -0
  10. package/src/lib/app-utils/business-profile.js.map +1 -0
  11. package/src/lib/app-utils/child-contract.d.ts +4 -1
  12. package/src/lib/app-utils/child-contract.js +5 -2
  13. package/src/lib/app-utils/child-contract.js.map +1 -1
  14. package/src/lib/app-utils/console-routes.d.ts +5 -0
  15. package/src/lib/app-utils/console-routes.js +1 -0
  16. package/src/lib/app-utils/console-routes.js.map +1 -1
  17. package/src/lib/app-utils/content-schema-type.d.ts +9 -0
  18. package/src/lib/app-utils/content-schema-type.js +4 -0
  19. package/src/lib/app-utils/content-schema-type.js.map +1 -1
  20. package/src/lib/app-utils/docs-help.generated.d.ts +41 -5
  21. package/src/lib/app-utils/docs-help.generated.js +83 -3
  22. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  23. package/src/lib/app-utils/docs-index.generated.js +181 -13
  24. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  25. package/src/lib/app-utils/entry-list-declaration.d.ts +33 -0
  26. package/src/lib/app-utils/entry-list-declaration.js +172 -0
  27. package/src/lib/app-utils/entry-list-declaration.js.map +1 -0
  28. package/src/lib/app-utils/first-version-seed.d.ts +19 -0
  29. package/src/lib/app-utils/first-version-seed.js +53 -0
  30. package/src/lib/app-utils/first-version-seed.js.map +1 -0
  31. package/src/lib/app-utils/local-business.d.ts +8 -0
  32. package/src/lib/app-utils/local-business.js +4 -0
  33. package/src/lib/app-utils/local-business.js.map +1 -1
  34. package/src/lib/app-utils/page-markdown.js +3 -0
  35. package/src/lib/app-utils/page-markdown.js.map +1 -1
  36. package/src/lib/plugin-manager/feature-plugins.d.ts +19 -0
  37. package/src/lib/plugin-manager/feature-plugins.js +8 -0
  38. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  39. package/src/lib/plugin-manager/first-party-plugins.generated.js +105 -1
  40. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  41. package/src/lib/plugin-manager/plugin-channel-orders.d.ts +262 -0
  42. package/src/lib/plugin-manager/plugin-channel-orders.js +32 -0
  43. package/src/lib/plugin-manager/plugin-channel-orders.js.map +1 -0
  44. package/src/lib/plugin-manager/plugin-product-catalog.d.ts +5 -0
  45. package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -1
  46. package/src/lib/plugin-manager/plugin-product-writer.d.ts +166 -0
  47. package/src/lib/plugin-manager/plugin-product-writer.js +31 -0
  48. package/src/lib/plugin-manager/plugin-product-writer.js.map +1 -0
  49. package/src/lib/plugin-manager/plugin-theme-presets.d.ts +40 -0
  50. package/src/lib/plugin-manager/plugin-theme-presets.js +44 -0
  51. package/src/lib/plugin-manager/plugin-theme-presets.js.map +1 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type HostSeoEntity } from './content-authors';
18
+ /**
19
+ * A site's BUSINESS PROFILE (AGL-3661): what the business is, as everything
20
+ * that writes about it needs to know — the site's own pages, the structured
21
+ * data, and any generator a plugin brings.
22
+ *
23
+ * ── Most of it is already stored, so this stores only the rest ─────────────
24
+ *
25
+ * The site's name, its one-line description, its contact details, its hours,
26
+ * the areas it serves and its social profiles all have a home on the host
27
+ * document already: `seo.entity` (the publisher entity the JSON-LD is built
28
+ * from, edited in Setup → SEO) and `business` (the contact card the `host.*`
29
+ * tokens and the footer read, edited in Setup → Basic details). Copying them
30
+ * here would make two places to keep in step, and the copy would drift.
31
+ *
32
+ * So the profile document holds only what has no home yet — the services, the
33
+ * audience, the tone of voice — plus a line about the business and the area it
34
+ * serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}
35
+ * reads the two together into one picture, field by field, and says where
36
+ * each value came from.
37
+ *
38
+ * ── Where it is stored ────────────────────────────────────────────────────
39
+ *
40
+ * `hosts/{hostId}/businessProfile/profile` for a site and
41
+ * `orgs/{orgId}/businessProfile/defaults` for the workspace, whose values a
42
+ * site inherits where it has none of its own. A subcollection document rather
43
+ * than a key on the host: the host document's `business` map is replaced
44
+ * whole by the contact card's save, and a key beside it would be one more
45
+ * thing a partial write could blank.
46
+ *
47
+ * ── Whose words win ───────────────────────────────────────────────────────
48
+ *
49
+ * Every stored value carries its source. What the owner typed is never
50
+ * replaced by anything else; what the guided start was told replaces only
51
+ * what a generator guessed; and a generator's guess fills only what is empty.
52
+ * {@link mergeBusinessProfilePrefill} is the one place that rule is applied.
53
+ */
54
+ /** The collection a profile document lives in, under a host or an org. */
55
+ export declare const BUSINESS_PROFILE_SUBCOLLECTION = "businessProfile";
56
+ /** The site's profile document id. */
57
+ export declare const BUSINESS_PROFILE_SITE_DOC = "profile";
58
+ /** The workspace defaults document id. */
59
+ export declare const BUSINESS_PROFILE_WORKSPACE_DOC = "defaults";
60
+ /** Who wrote a stored value: the owner, the guided start's answers, or a generator. */
61
+ export type BusinessProfileSource = 'owner' | 'start' | 'ai';
62
+ /**
63
+ * Where a RESOLVED value came from: a stored source, the site's own settings
64
+ * (`seo.entity`, `business`, the display name), or the workspace defaults.
65
+ */
66
+ export type BusinessProfileOrigin = BusinessProfileSource | 'site' | 'workspace';
67
+ export declare const BUSINESS_TONES: readonly ["friendly", "professional", "playful", "premium", "plain"];
68
+ export type BusinessTone = (typeof BUSINESS_TONES)[number];
69
+ /** Each tone as the owner picks it and as a writer is told it. */
70
+ export declare const BUSINESS_TONE_LABELS: Readonly<Record<BusinessTone, string>>;
71
+ /** The fields the profile document stores itself. */
72
+ export declare const BUSINESS_PROFILE_FIELDS: readonly ["whatYouDo", "services", "serviceArea", "audience", "tone", "toneNotes"];
73
+ export type BusinessProfileField = (typeof BUSINESS_PROFILE_FIELDS)[number];
74
+ /** Each field's ceiling, in characters; `services` is per item. */
75
+ export declare const BUSINESS_PROFILE_MAX_CHARS: Readonly<Record<Exclude<BusinessProfileField, 'tone'>, number>>;
76
+ /** At most this many services are kept. */
77
+ export declare const BUSINESS_PROFILE_MAX_SERVICES = 12;
78
+ /** The stored document, at either level. */
79
+ export interface BusinessProfileDoc {
80
+ whatYouDo?: string;
81
+ services?: string[];
82
+ serviceArea?: string;
83
+ audience?: string;
84
+ tone?: BusinessTone | null;
85
+ toneNotes?: string;
86
+ /** Who wrote each stored value; a value without one is the owner's. */
87
+ sources?: Partial<Record<BusinessProfileField, BusinessProfileSource>>;
88
+ updatedAt?: unknown;
89
+ updatedBy?: string | null;
90
+ }
91
+ /** The values a write carries, before they are sanitized. */
92
+ export type BusinessProfileValues = Partial<Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes'>>;
93
+ /** The host fields the profile reads. A closed surface, as the host tokens' is. */
94
+ export interface BusinessProfileHost {
95
+ displayName?: string;
96
+ logoUrl?: string;
97
+ seo?: {
98
+ description?: string;
99
+ entity?: HostSeoEntity | null;
100
+ } | null;
101
+ business?: {
102
+ supportEmail?: string;
103
+ address?: string;
104
+ socialLinks?: Array<{
105
+ label?: string;
106
+ url?: string;
107
+ } | null> | null;
108
+ } | null;
109
+ }
110
+ export interface ResolvedBusinessField<T> {
111
+ value: T;
112
+ origin: BusinessProfileOrigin;
113
+ }
114
+ /** The picture every reader works from: each field's value and where it came from. */
115
+ export interface ResolvedBusinessProfile {
116
+ name: ResolvedBusinessField<string> | null;
117
+ whatYouDo: ResolvedBusinessField<string> | null;
118
+ services: ResolvedBusinessField<string[]> | null;
119
+ serviceArea: ResolvedBusinessField<string> | null;
120
+ audience: ResolvedBusinessField<string> | null;
121
+ tone: ResolvedBusinessField<BusinessTone> | null;
122
+ toneNotes: ResolvedBusinessField<string> | null;
123
+ /** The schema.org business type the SEO entity names, e.g. `Plumber`. */
124
+ businessType: string | null;
125
+ /** Real contact details, each only where the owner entered it in the site's settings. */
126
+ contact: {
127
+ email: string | null;
128
+ phone: string | null;
129
+ address: string | null;
130
+ hours: string | null;
131
+ };
132
+ /** The site's social and other profiles, https only. */
133
+ profiles: string[];
134
+ logo: string | null;
135
+ }
136
+ /** The source a stored, non-empty value carries; the owner's when none is recorded. */
137
+ export declare function businessProfileSourceOf(doc: BusinessProfileDoc | null | undefined, field: BusinessProfileField): BusinessProfileSource | null;
138
+ /** Every value a write may carry, sanitized; an absent field stays absent. */
139
+ export declare function sanitizeBusinessProfileValues(values: BusinessProfileValues): BusinessProfileValues;
140
+ /**
141
+ * The document a generator's or the guided start's values make of the stored
142
+ * one: each value fills a field that is empty or was written by a source that
143
+ * weighs less, and nothing else. `null` when nothing would change, so a caller
144
+ * writes only when there is something to write.
145
+ */
146
+ export declare function mergeBusinessProfilePrefill(current: BusinessProfileDoc | null | undefined, values: BusinessProfileValues, source: Exclude<BusinessProfileSource, 'owner'>): BusinessProfileDoc | null;
147
+ /**
148
+ * The document the owner's save makes: every field they changed becomes
149
+ * theirs, and a field they left as it was keeps the source it had — so saving
150
+ * the form after correcting one line does not claim every suggestion on it.
151
+ */
152
+ export declare function businessProfileOwnerWrite(current: BusinessProfileDoc | null | undefined, edited: BusinessProfileValues): Pick<BusinessProfileDoc, BusinessProfileField | 'sources'>;
153
+ /**
154
+ * The profile as every reader sees it, field by field (AGL-3661):
155
+ *
156
+ * 1. what the OWNER typed into the site's profile;
157
+ * 2. what the site's own settings say (the SEO entity, the contact card, the
158
+ * display name), which the owner typed too, elsewhere;
159
+ * 3. what the guided start or a generator stored on the site's profile;
160
+ * 4. the workspace defaults.
161
+ *
162
+ * Contact details, the logo and the profiles come from the site's settings
163
+ * only: nothing but the owner's own entry may say how to reach the business.
164
+ */
165
+ export declare function resolveBusinessProfile(input: {
166
+ host: BusinessProfileHost | null | undefined;
167
+ site?: BusinessProfileDoc | null;
168
+ workspace?: BusinessProfileDoc | null;
169
+ }): ResolvedBusinessProfile;
170
+ /** Each origin as the profile screen labels it. */
171
+ export declare const BUSINESS_PROFILE_ORIGIN_LABELS: Readonly<Record<BusinessProfileOrigin, string>>;
@@ -0,0 +1,277 @@
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 { siteProfileUrls } from "./content-authors.js";
18
+ import { normalizeAreaServed } from "./local-business.js";
19
+ import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
20
+ /**
21
+ * A site's BUSINESS PROFILE (AGL-3661): what the business is, as everything
22
+ * that writes about it needs to know — the site's own pages, the structured
23
+ * data, and any generator a plugin brings.
24
+ *
25
+ * ── Most of it is already stored, so this stores only the rest ─────────────
26
+ *
27
+ * The site's name, its one-line description, its contact details, its hours,
28
+ * the areas it serves and its social profiles all have a home on the host
29
+ * document already: `seo.entity` (the publisher entity the JSON-LD is built
30
+ * from, edited in Setup → SEO) and `business` (the contact card the `host.*`
31
+ * tokens and the footer read, edited in Setup → Basic details). Copying them
32
+ * here would make two places to keep in step, and the copy would drift.
33
+ *
34
+ * So the profile document holds only what has no home yet — the services, the
35
+ * audience, the tone of voice — plus a line about the business and the area it
36
+ * serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}
37
+ * reads the two together into one picture, field by field, and says where
38
+ * each value came from.
39
+ *
40
+ * ── Where it is stored ────────────────────────────────────────────────────
41
+ *
42
+ * `hosts/{hostId}/businessProfile/profile` for a site and
43
+ * `orgs/{orgId}/businessProfile/defaults` for the workspace, whose values a
44
+ * site inherits where it has none of its own. A subcollection document rather
45
+ * than a key on the host: the host document's `business` map is replaced
46
+ * whole by the contact card's save, and a key beside it would be one more
47
+ * thing a partial write could blank.
48
+ *
49
+ * ── Whose words win ───────────────────────────────────────────────────────
50
+ *
51
+ * Every stored value carries its source. What the owner typed is never
52
+ * replaced by anything else; what the guided start was told replaces only
53
+ * what a generator guessed; and a generator's guess fills only what is empty.
54
+ * {@link mergeBusinessProfilePrefill} is the one place that rule is applied.
55
+ */ /** The collection a profile document lives in, under a host or an org. */ export const BUSINESS_PROFILE_SUBCOLLECTION = 'businessProfile';
56
+ /** The site's profile document id. */ export const BUSINESS_PROFILE_SITE_DOC = 'profile';
57
+ /** The workspace defaults document id. */ export const BUSINESS_PROFILE_WORKSPACE_DOC = 'defaults';
58
+ /** How much a source's words weigh: a higher rank is never replaced by a lower one. */ const SOURCE_RANK = {
59
+ ai: 1,
60
+ start: 2,
61
+ owner: 3
62
+ };
63
+ export const BUSINESS_TONES = [
64
+ 'friendly',
65
+ 'professional',
66
+ 'playful',
67
+ 'premium',
68
+ 'plain'
69
+ ];
70
+ /** Each tone as the owner picks it and as a writer is told it. */ export const BUSINESS_TONE_LABELS = {
71
+ friendly: 'Friendly and warm',
72
+ professional: 'Professional and clear',
73
+ playful: 'Playful and upbeat',
74
+ premium: 'Premium and refined',
75
+ plain: 'Plain and direct'
76
+ };
77
+ /** The fields the profile document stores itself. */ export const BUSINESS_PROFILE_FIELDS = [
78
+ 'whatYouDo',
79
+ 'services',
80
+ 'serviceArea',
81
+ 'audience',
82
+ 'tone',
83
+ 'toneNotes'
84
+ ];
85
+ /** Each field's ceiling, in characters; `services` is per item. */ export const BUSINESS_PROFILE_MAX_CHARS = {
86
+ whatYouDo: 200,
87
+ services: 60,
88
+ serviceArea: 120,
89
+ audience: 160,
90
+ toneNotes: 200
91
+ };
92
+ /** At most this many services are kept. */ export const BUSINESS_PROFILE_MAX_SERVICES = 12;
93
+ const text = (value, max)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '';
94
+ const tone = (value)=>BUSINESS_TONES.includes(value) ? value : null;
95
+ const services = (value)=>{
96
+ const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[,\n]/) : [];
97
+ const seen = new Set();
98
+ const kept = [];
99
+ for (const item of items){
100
+ const name = text(item, BUSINESS_PROFILE_MAX_CHARS.services);
101
+ const key = name.toLowerCase();
102
+ if (!name || seen.has(key)) continue;
103
+ seen.add(key);
104
+ kept.push(name);
105
+ if (kept.length >= BUSINESS_PROFILE_MAX_SERVICES) break;
106
+ }
107
+ return kept;
108
+ };
109
+ /** One field's stored value, sanitized; empty is `''`, `[]` or `null`. */ function fieldValue(doc, field) {
110
+ switch(field){
111
+ case 'services':
112
+ return services(doc == null ? void 0 : doc.services);
113
+ case 'tone':
114
+ return tone(doc == null ? void 0 : doc.tone);
115
+ default:
116
+ return text(doc == null ? void 0 : doc[field], BUSINESS_PROFILE_MAX_CHARS[field]);
117
+ }
118
+ }
119
+ const isEmpty = (value)=>value === null || value === '' || Array.isArray(value) && value.length === 0;
120
+ /** The source a stored, non-empty value carries; the owner's when none is recorded. */ export function businessProfileSourceOf(doc, field) {
121
+ var _doc_sources;
122
+ if (isEmpty(fieldValue(doc, field))) return null;
123
+ const source = doc == null ? void 0 : (_doc_sources = doc.sources) == null ? void 0 : _doc_sources[field];
124
+ return source && source in SOURCE_RANK ? source : 'owner';
125
+ }
126
+ /** Every value a write may carry, sanitized; an absent field stays absent. */ export function sanitizeBusinessProfileValues(values) {
127
+ const clean = {};
128
+ for (const field of BUSINESS_PROFILE_FIELDS){
129
+ if (!(field in values)) continue;
130
+ clean[field] = fieldValue(values, field);
131
+ }
132
+ return clean;
133
+ }
134
+ /**
135
+ * The document a generator's or the guided start's values make of the stored
136
+ * one: each value fills a field that is empty or was written by a source that
137
+ * weighs less, and nothing else. `null` when nothing would change, so a caller
138
+ * writes only when there is something to write.
139
+ */ export function mergeBusinessProfilePrefill(current, values, source) {
140
+ var _ref;
141
+ const clean = sanitizeBusinessProfileValues(values);
142
+ const next = _extends({}, current != null ? current : {}, {
143
+ sources: _extends({}, (_ref = current == null ? void 0 : current.sources) != null ? _ref : {})
144
+ });
145
+ let changed = false;
146
+ for (const field of BUSINESS_PROFILE_FIELDS){
147
+ var _current_sources;
148
+ if (!(field in clean)) continue;
149
+ const value = clean[field];
150
+ if (isEmpty(value)) continue;
151
+ // An explicit source counts even on an empty value: a field the owner
152
+ // cleared stays cleared rather than being refilled behind them.
153
+ const recorded = current == null ? void 0 : (_current_sources = current.sources) == null ? void 0 : _current_sources[field];
154
+ const held = recorded && recorded in SOURCE_RANK ? recorded : businessProfileSourceOf(current, field);
155
+ if (held && SOURCE_RANK[held] >= SOURCE_RANK[source]) continue;
156
+ if (JSON.stringify(fieldValue(current, field)) === JSON.stringify(value)) continue;
157
+ next[field] = value;
158
+ next.sources[field] = source;
159
+ changed = true;
160
+ }
161
+ return changed ? next : null;
162
+ }
163
+ /**
164
+ * The document the owner's save makes: every field they changed becomes
165
+ * theirs, and a field they left as it was keeps the source it had — so saving
166
+ * the form after correcting one line does not claim every suggestion on it.
167
+ */ export function businessProfileOwnerWrite(current, edited) {
168
+ const clean = sanitizeBusinessProfileValues(edited);
169
+ const next = {};
170
+ const sources = {};
171
+ for (const field of BUSINESS_PROFILE_FIELDS){
172
+ var _businessProfileSourceOf;
173
+ const before = fieldValue(current, field);
174
+ const after = field in clean ? clean[field] : before;
175
+ next[field] = after;
176
+ const same = JSON.stringify(before) === JSON.stringify(after);
177
+ if (isEmpty(after)) {
178
+ var _current_sources;
179
+ // A suggestion the owner deleted is their decision, and recorded as
180
+ // one, so the next prefill does not put it back.
181
+ if (!same) sources[field] = 'owner';
182
+ else if ((current == null ? void 0 : (_current_sources = current.sources) == null ? void 0 : _current_sources[field]) === 'owner') sources[field] = 'owner';
183
+ continue;
184
+ }
185
+ sources[field] = same ? (_businessProfileSourceOf = businessProfileSourceOf(current, field)) != null ? _businessProfileSourceOf : 'owner' : 'owner';
186
+ }
187
+ return _extends({}, next, {
188
+ sources
189
+ });
190
+ }
191
+ /** The postal address as one line: the SEO entity's parts, else the contact card's block. */ function addressLine(host) {
192
+ var _host_seo_entity, _host_seo, _host_business;
193
+ const parts = host == null ? void 0 : (_host_seo = host.seo) == null ? void 0 : (_host_seo_entity = _host_seo.entity) == null ? void 0 : _host_seo_entity.address;
194
+ const line = [
195
+ parts == null ? void 0 : parts.streetAddress,
196
+ parts == null ? void 0 : parts.addressLocality,
197
+ parts == null ? void 0 : parts.addressRegion,
198
+ parts == null ? void 0 : parts.postalCode,
199
+ parts == null ? void 0 : parts.addressCountry
200
+ ].map((part)=>text(part, 200)).filter(Boolean).join(', ');
201
+ return line || text(host == null ? void 0 : (_host_business = host.business) == null ? void 0 : _host_business.address, 400);
202
+ }
203
+ /**
204
+ * The profile as every reader sees it, field by field (AGL-3661):
205
+ *
206
+ * 1. what the OWNER typed into the site's profile;
207
+ * 2. what the site's own settings say (the SEO entity, the contact card, the
208
+ * display name), which the owner typed too, elsewhere;
209
+ * 3. what the guided start or a generator stored on the site's profile;
210
+ * 4. the workspace defaults.
211
+ *
212
+ * Contact details, the logo and the profiles come from the site's settings
213
+ * only: nothing but the owner's own entry may say how to reach the business.
214
+ */ export function resolveBusinessProfile(input) {
215
+ var _ref;
216
+ var _host_seo, _host_business;
217
+ const { host, site, workspace } = input;
218
+ const entity = (_ref = host == null ? void 0 : (_host_seo = host.seo) == null ? void 0 : _host_seo.entity) != null ? _ref : null;
219
+ const fromSite = {
220
+ whatYouDo: text(entity == null ? void 0 : entity.description, BUSINESS_PROFILE_MAX_CHARS.whatYouDo),
221
+ serviceArea: normalizeAreaServed(entity == null ? void 0 : entity.areaServed).join(', ').slice(0, BUSINESS_PROFILE_MAX_CHARS.serviceArea)
222
+ };
223
+ const resolve = (field)=>{
224
+ const stored = businessProfileSourceOf(site, field);
225
+ if (stored === 'owner') return {
226
+ value: fieldValue(site, field),
227
+ origin: 'owner'
228
+ };
229
+ const own = fromSite[field];
230
+ if (own !== undefined && !isEmpty(own)) return {
231
+ value: own,
232
+ origin: 'site'
233
+ };
234
+ if (stored) return {
235
+ value: fieldValue(site, field),
236
+ origin: stored
237
+ };
238
+ if (businessProfileSourceOf(workspace, field)) {
239
+ return {
240
+ value: fieldValue(workspace, field),
241
+ origin: 'workspace'
242
+ };
243
+ }
244
+ return null;
245
+ };
246
+ const name = text(entity == null ? void 0 : entity.name, 200) || text(host == null ? void 0 : host.displayName, 200);
247
+ return {
248
+ name: name ? {
249
+ value: name,
250
+ origin: 'site'
251
+ } : null,
252
+ whatYouDo: resolve('whatYouDo'),
253
+ services: resolve('services'),
254
+ serviceArea: resolve('serviceArea'),
255
+ audience: resolve('audience'),
256
+ tone: resolve('tone'),
257
+ toneNotes: resolve('toneNotes'),
258
+ businessType: text(entity == null ? void 0 : entity.businessType, 80) || null,
259
+ contact: {
260
+ email: text(entity == null ? void 0 : entity.email, 320) || text(host == null ? void 0 : (_host_business = host.business) == null ? void 0 : _host_business.supportEmail, 320) || null,
261
+ phone: text(entity == null ? void 0 : entity.telephone, 64) || null,
262
+ address: addressLine(host) || null,
263
+ hours: text(entity == null ? void 0 : entity.openingHours, 400) || null
264
+ },
265
+ profiles: siteProfileUrls(host),
266
+ logo: text(host == null ? void 0 : host.logoUrl, 800) || text(entity == null ? void 0 : entity.logo, 800) || null
267
+ };
268
+ }
269
+ /** Each origin as the profile screen labels it. */ export const BUSINESS_PROFILE_ORIGIN_LABELS = {
270
+ owner: 'You',
271
+ site: 'Site settings',
272
+ start: 'Your answers when the site was started',
273
+ ai: `Suggested by ${PLATFORM_BRAND_NAME} AI`,
274
+ workspace: 'Workspace default'
275
+ };
276
+
277
+ //# sourceMappingURL=business-profile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/business-profile.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 { siteProfileUrls, type HostSeoEntity } from './content-authors'\nimport { normalizeAreaServed } from './local-business'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/**\n * A site's BUSINESS PROFILE (AGL-3661): what the business is, as everything\n * that writes about it needs to know — the site's own pages, the structured\n * data, and any generator a plugin brings.\n *\n * ── Most of it is already stored, so this stores only the rest ─────────────\n *\n * The site's name, its one-line description, its contact details, its hours,\n * the areas it serves and its social profiles all have a home on the host\n * document already: `seo.entity` (the publisher entity the JSON-LD is built\n * from, edited in Setup → SEO) and `business` (the contact card the `host.*`\n * tokens and the footer read, edited in Setup → Basic details). Copying them\n * here would make two places to keep in step, and the copy would drift.\n *\n * So the profile document holds only what has no home yet — the services, the\n * audience, the tone of voice — plus a line about the business and the area it\n * serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}\n * reads the two together into one picture, field by field, and says where\n * each value came from.\n *\n * ── Where it is stored ────────────────────────────────────────────────────\n *\n * `hosts/{hostId}/businessProfile/profile` for a site and\n * `orgs/{orgId}/businessProfile/defaults` for the workspace, whose values a\n * site inherits where it has none of its own. A subcollection document rather\n * than a key on the host: the host document's `business` map is replaced\n * whole by the contact card's save, and a key beside it would be one more\n * thing a partial write could blank.\n *\n * ── Whose words win ───────────────────────────────────────────────────────\n *\n * Every stored value carries its source. What the owner typed is never\n * replaced by anything else; what the guided start was told replaces only\n * what a generator guessed; and a generator's guess fills only what is empty.\n * {@link mergeBusinessProfilePrefill} is the one place that rule is applied.\n */\n\n/** The collection a profile document lives in, under a host or an org. */\nexport const BUSINESS_PROFILE_SUBCOLLECTION = 'businessProfile'\n/** The site's profile document id. */\nexport const BUSINESS_PROFILE_SITE_DOC = 'profile'\n/** The workspace defaults document id. */\nexport const BUSINESS_PROFILE_WORKSPACE_DOC = 'defaults'\n\n/** Who wrote a stored value: the owner, the guided start's answers, or a generator. */\nexport type BusinessProfileSource = 'owner' | 'start' | 'ai'\n\n/**\n * Where a RESOLVED value came from: a stored source, the site's own settings\n * (`seo.entity`, `business`, the display name), or the workspace defaults.\n */\nexport type BusinessProfileOrigin = BusinessProfileSource | 'site' | 'workspace'\n\n/** How much a source's words weigh: a higher rank is never replaced by a lower one. */\nconst SOURCE_RANK: Readonly<Record<BusinessProfileSource, number>> = {\n ai: 1,\n start: 2,\n owner: 3,\n}\n\nexport const BUSINESS_TONES = ['friendly', 'professional', 'playful', 'premium', 'plain'] as const\nexport type BusinessTone = (typeof BUSINESS_TONES)[number]\n\n/** Each tone as the owner picks it and as a writer is told it. */\nexport const BUSINESS_TONE_LABELS: Readonly<Record<BusinessTone, string>> = {\n friendly: 'Friendly and warm',\n professional: 'Professional and clear',\n playful: 'Playful and upbeat',\n premium: 'Premium and refined',\n plain: 'Plain and direct',\n}\n\n/** The fields the profile document stores itself. */\nexport const BUSINESS_PROFILE_FIELDS = [\n 'whatYouDo',\n 'services',\n 'serviceArea',\n 'audience',\n 'tone',\n 'toneNotes',\n] as const\nexport type BusinessProfileField = (typeof BUSINESS_PROFILE_FIELDS)[number]\n\n/** Each field's ceiling, in characters; `services` is per item. */\nexport const BUSINESS_PROFILE_MAX_CHARS: Readonly<Record<Exclude<BusinessProfileField, 'tone'>, number>> = {\n whatYouDo: 200,\n services: 60,\n serviceArea: 120,\n audience: 160,\n toneNotes: 200,\n}\n\n/** At most this many services are kept. */\nexport const BUSINESS_PROFILE_MAX_SERVICES = 12\n\n/** The stored document, at either level. */\nexport interface BusinessProfileDoc {\n whatYouDo?: string\n services?: string[]\n serviceArea?: string\n audience?: string\n tone?: BusinessTone | null\n toneNotes?: string\n /** Who wrote each stored value; a value without one is the owner's. */\n sources?: Partial<Record<BusinessProfileField, BusinessProfileSource>>\n updatedAt?: unknown\n updatedBy?: string | null\n}\n\n/** The values a write carries, before they are sanitized. */\nexport type BusinessProfileValues = Partial<\n Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes'>\n>\n\n/** The host fields the profile reads. A closed surface, as the host tokens' is. */\nexport interface BusinessProfileHost {\n displayName?: string\n logoUrl?: string\n seo?: { description?: string; entity?: HostSeoEntity | null } | null\n business?: {\n supportEmail?: string\n address?: string\n socialLinks?: Array<{ label?: string; url?: string } | null> | null\n } | null\n}\n\nexport interface ResolvedBusinessField<T> {\n value: T\n origin: BusinessProfileOrigin\n}\n\n/** The picture every reader works from: each field's value and where it came from. */\nexport interface ResolvedBusinessProfile {\n name: ResolvedBusinessField<string> | null\n whatYouDo: ResolvedBusinessField<string> | null\n services: ResolvedBusinessField<string[]> | null\n serviceArea: ResolvedBusinessField<string> | null\n audience: ResolvedBusinessField<string> | null\n tone: ResolvedBusinessField<BusinessTone> | null\n toneNotes: ResolvedBusinessField<string> | null\n /** The schema.org business type the SEO entity names, e.g. `Plumber`. */\n businessType: string | null\n /** Real contact details, each only where the owner entered it in the site's settings. */\n contact: {\n email: string | null\n phone: string | null\n address: string | null\n hours: string | null\n }\n /** The site's social and other profiles, https only. */\n profiles: string[]\n logo: string | null\n}\n\nconst text = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim().slice(0, max) : ''\n\nconst tone = (value: unknown): BusinessTone | null =>\n (BUSINESS_TONES as readonly unknown[]).includes(value) ? (value as BusinessTone) : null\n\nconst services = (value: unknown): string[] => {\n const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[,\\n]/) : []\n const seen = new Set<string>()\n const kept: string[] = []\n for (const item of items) {\n const name = text(item, BUSINESS_PROFILE_MAX_CHARS.services)\n const key = name.toLowerCase()\n if (!name || seen.has(key)) continue\n seen.add(key)\n kept.push(name)\n if (kept.length >= BUSINESS_PROFILE_MAX_SERVICES) break\n }\n return kept\n}\n\n/** One field's stored value, sanitized; empty is `''`, `[]` or `null`. */\nfunction fieldValue(doc: BusinessProfileValues | null | undefined, field: BusinessProfileField): unknown {\n switch (field) {\n case 'services':\n return services(doc?.services)\n case 'tone':\n return tone(doc?.tone)\n default:\n return text(doc?.[field], BUSINESS_PROFILE_MAX_CHARS[field])\n }\n}\n\nconst isEmpty = (value: unknown): boolean =>\n value === null || value === '' || (Array.isArray(value) && value.length === 0)\n\n/** The source a stored, non-empty value carries; the owner's when none is recorded. */\nexport function businessProfileSourceOf(\n doc: BusinessProfileDoc | null | undefined,\n field: BusinessProfileField,\n): BusinessProfileSource | null {\n if (isEmpty(fieldValue(doc, field))) return null\n const source = doc?.sources?.[field]\n return source && source in SOURCE_RANK ? source : 'owner'\n}\n\n/** Every value a write may carry, sanitized; an absent field stays absent. */\nexport function sanitizeBusinessProfileValues(values: BusinessProfileValues): BusinessProfileValues {\n const clean: BusinessProfileValues = {}\n for (const field of BUSINESS_PROFILE_FIELDS) {\n if (!(field in values)) continue\n ;(clean as Record<string, unknown>)[field] = fieldValue(values, field)\n }\n return clean\n}\n\n/**\n * The document a generator's or the guided start's values make of the stored\n * one: each value fills a field that is empty or was written by a source that\n * weighs less, and nothing else. `null` when nothing would change, so a caller\n * writes only when there is something to write.\n */\nexport function mergeBusinessProfilePrefill(\n current: BusinessProfileDoc | null | undefined,\n values: BusinessProfileValues,\n source: Exclude<BusinessProfileSource, 'owner'>,\n): BusinessProfileDoc | null {\n const clean = sanitizeBusinessProfileValues(values)\n const next: BusinessProfileDoc = { ...(current ?? {}), sources: { ...(current?.sources ?? {}) } }\n let changed = false\n for (const field of BUSINESS_PROFILE_FIELDS) {\n if (!(field in clean)) continue\n const value = (clean as Record<string, unknown>)[field]\n if (isEmpty(value)) continue\n // An explicit source counts even on an empty value: a field the owner\n // cleared stays cleared rather than being refilled behind them.\n const recorded = current?.sources?.[field]\n const held = recorded && recorded in SOURCE_RANK ? recorded : businessProfileSourceOf(current, field)\n if (held && SOURCE_RANK[held] >= SOURCE_RANK[source]) continue\n if (JSON.stringify(fieldValue(current, field)) === JSON.stringify(value)) continue\n ;(next as Record<string, unknown>)[field] = value\n next.sources![field] = source\n changed = true\n }\n return changed ? next : null\n}\n\n/**\n * The document the owner's save makes: every field they changed becomes\n * theirs, and a field they left as it was keeps the source it had — so saving\n * the form after correcting one line does not claim every suggestion on it.\n */\nexport function businessProfileOwnerWrite(\n current: BusinessProfileDoc | null | undefined,\n edited: BusinessProfileValues,\n): Pick<BusinessProfileDoc, BusinessProfileField | 'sources'> {\n const clean = sanitizeBusinessProfileValues(edited)\n const next: Record<string, unknown> = {}\n const sources: Partial<Record<BusinessProfileField, BusinessProfileSource>> = {}\n for (const field of BUSINESS_PROFILE_FIELDS) {\n const before = fieldValue(current, field)\n const after = field in clean ? (clean as Record<string, unknown>)[field] : before\n next[field] = after\n const same = JSON.stringify(before) === JSON.stringify(after)\n if (isEmpty(after)) {\n // A suggestion the owner deleted is their decision, and recorded as\n // one, so the next prefill does not put it back.\n if (!same) sources[field] = 'owner'\n else if (current?.sources?.[field] === 'owner') sources[field] = 'owner'\n continue\n }\n sources[field] = same ? (businessProfileSourceOf(current, field) ?? 'owner') : 'owner'\n }\n return { ...(next as Pick<BusinessProfileDoc, BusinessProfileField>), sources }\n}\n\n/** The postal address as one line: the SEO entity's parts, else the contact card's block. */\nfunction addressLine(host: BusinessProfileHost | null | undefined): string {\n const parts = host?.seo?.entity?.address\n const line = [\n parts?.streetAddress,\n parts?.addressLocality,\n parts?.addressRegion,\n parts?.postalCode,\n parts?.addressCountry,\n ]\n .map((part) => text(part, 200))\n .filter(Boolean)\n .join(', ')\n return line || text(host?.business?.address, 400)\n}\n\n/**\n * The profile as every reader sees it, field by field (AGL-3661):\n *\n * 1. what the OWNER typed into the site's profile;\n * 2. what the site's own settings say (the SEO entity, the contact card, the\n * display name), which the owner typed too, elsewhere;\n * 3. what the guided start or a generator stored on the site's profile;\n * 4. the workspace defaults.\n *\n * Contact details, the logo and the profiles come from the site's settings\n * only: nothing but the owner's own entry may say how to reach the business.\n */\nexport function resolveBusinessProfile(input: {\n host: BusinessProfileHost | null | undefined\n site?: BusinessProfileDoc | null\n workspace?: BusinessProfileDoc | null\n}): ResolvedBusinessProfile {\n const { host, site, workspace } = input\n const entity = host?.seo?.entity ?? null\n const fromSite: Partial<Record<BusinessProfileField, unknown>> = {\n whatYouDo: text(entity?.description, BUSINESS_PROFILE_MAX_CHARS.whatYouDo),\n serviceArea: normalizeAreaServed(entity?.areaServed).join(', ').slice(0, BUSINESS_PROFILE_MAX_CHARS.serviceArea),\n }\n\n const resolve = <T>(field: BusinessProfileField): ResolvedBusinessField<T> | null => {\n const stored = businessProfileSourceOf(site, field)\n if (stored === 'owner') return { value: fieldValue(site, field) as T, origin: 'owner' }\n const own = fromSite[field]\n if (own !== undefined && !isEmpty(own)) return { value: own as T, origin: 'site' }\n if (stored) return { value: fieldValue(site, field) as T, origin: stored }\n if (businessProfileSourceOf(workspace, field)) {\n return { value: fieldValue(workspace, field) as T, origin: 'workspace' }\n }\n return null\n }\n\n const name = text(entity?.name, 200) || text(host?.displayName, 200)\n return {\n name: name ? { value: name, origin: 'site' } : null,\n whatYouDo: resolve<string>('whatYouDo'),\n services: resolve<string[]>('services'),\n serviceArea: resolve<string>('serviceArea'),\n audience: resolve<string>('audience'),\n tone: resolve<BusinessTone>('tone'),\n toneNotes: resolve<string>('toneNotes'),\n businessType: text(entity?.businessType, 80) || null,\n contact: {\n email: text(entity?.email, 320) || text(host?.business?.supportEmail, 320) || null,\n phone: text(entity?.telephone, 64) || null,\n address: addressLine(host) || null,\n hours: text(entity?.openingHours, 400) || null,\n },\n profiles: siteProfileUrls(host as Parameters<typeof siteProfileUrls>[0]),\n logo: text(host?.logoUrl, 800) || text(entity?.logo, 800) || null,\n }\n}\n\n/** Each origin as the profile screen labels it. */\nexport const BUSINESS_PROFILE_ORIGIN_LABELS: Readonly<Record<BusinessProfileOrigin, string>> = {\n owner: 'You',\n site: 'Site settings',\n start: 'Your answers when the site was started',\n ai: `Suggested by ${PLATFORM_BRAND_NAME} AI`,\n workspace: 'Workspace default',\n}\n"],"names":["siteProfileUrls","normalizeAreaServed","PLATFORM_BRAND_NAME","BUSINESS_PROFILE_SUBCOLLECTION","BUSINESS_PROFILE_SITE_DOC","BUSINESS_PROFILE_WORKSPACE_DOC","SOURCE_RANK","ai","start","owner","BUSINESS_TONES","BUSINESS_TONE_LABELS","friendly","professional","playful","premium","plain","BUSINESS_PROFILE_FIELDS","BUSINESS_PROFILE_MAX_CHARS","whatYouDo","services","serviceArea","audience","toneNotes","BUSINESS_PROFILE_MAX_SERVICES","text","value","max","replace","trim","slice","tone","includes","items","Array","isArray","split","seen","Set","kept","item","name","key","toLowerCase","has","add","push","length","fieldValue","doc","field","isEmpty","businessProfileSourceOf","source","sources","sanitizeBusinessProfileValues","values","clean","mergeBusinessProfilePrefill","current","next","changed","recorded","held","JSON","stringify","businessProfileOwnerWrite","edited","before","after","same","addressLine","host","parts","seo","entity","address","line","streetAddress","addressLocality","addressRegion","postalCode","addressCountry","map","part","filter","Boolean","join","business","resolveBusinessProfile","input","site","workspace","fromSite","description","areaServed","resolve","stored","origin","own","undefined","displayName","businessType","contact","email","supportEmail","phone","telephone","hours","openingHours","profiles","logo","logoUrl","BUSINESS_PROFILE_ORIGIN_LABELS"],"mappings":"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"}
@@ -79,7 +79,10 @@ export declare function isFlagDisabled(flag?: FEATURE_FLAG): boolean;
79
79
  *
80
80
  * - `flags.selfClosing` — an image, an icon, a divider.
81
81
  * - `flags.textEditable` — the component renders `children` as editable text,
82
- * so an element dropped in would be destroyed by the next text edit.
82
+ * so an element dropped in would be destroyed by the next text edit —
83
+ * unless the schema also says `flags.dropping: ENABLED`. That is a promise
84
+ * the component keeps its text in its own `<aglyn-text>` beside the child
85
+ * elements, which is the only part the in-place editor rewrites (AGL-3672).
83
86
  * - `flags.dropping: DISABLED` — neither of the above, but still no slot
84
87
  * (AGL-1388): Markdown renders its parsed `content` prop and nothing else;
85
88
  * a Reusable Component instance has its child list REPLACED by the grafted
@@ -80,7 +80,10 @@
80
80
  *
81
81
  * - `flags.selfClosing` — an image, an icon, a divider.
82
82
  * - `flags.textEditable` — the component renders `children` as editable text,
83
- * so an element dropped in would be destroyed by the next text edit.
83
+ * so an element dropped in would be destroyed by the next text edit —
84
+ * unless the schema also says `flags.dropping: ENABLED`. That is a promise
85
+ * the component keeps its text in its own `<aglyn-text>` beside the child
86
+ * elements, which is the only part the in-place editor rewrites (AGL-3672).
84
87
  * - `flags.dropping: DISABLED` — neither of the above, but still no slot
85
88
  * (AGL-1388): Markdown renders its parsed `content` prop and nothing else;
86
89
  * a Reusable Component instance has its child list REPLACED by the grafted
@@ -98,7 +101,7 @@
98
101
  const flags = schema.flags;
99
102
  if (isFlagDisabled(flags == null ? void 0 : flags.dropping)) return false;
100
103
  if (isLeafFlagEnabled(flags == null ? void 0 : flags.selfClosing)) return false;
101
- if (isLeafFlagEnabled(flags == null ? void 0 : flags.textEditable)) return false;
104
+ if (isLeafFlagEnabled(flags == null ? void 0 : flags.textEditable) && !isLeafFlagEnabled(flags == null ? void 0 : flags.dropping)) return false;
102
105
  return !restrictsChildrenToNothing(schema.restrictChildren);
103
106
  }
104
107
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/child-contract.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\n/**\n * The drop/render agreement (AGL-1389).\n *\n * A component that accepts a drop in the hierarchy and discards the node at\n * render is silent data loss: the author sees the node in the tree, the\n * published page does not have it, and nothing warns — so the work reads as\n * *never done* rather than broken. Three /press screenshots shipped in a page\n * payload for weeks that way (AGL-1388).\n *\n * That was found by reading all 82 registered schemas by hand, one at a time.\n * This module is the repeatable half: the predicate the editor actually gates\n * drops on, plus an audit that turns \"someone added a component and never\n * thought about children\" into a red test.\n *\n * Deliberately NOT a render sweep. Rendering every schema with a sentinel\n * child fails for components that legitimately hide children behind state\n * (a collapsed Accordion, a closed Drawer, a `lazyPanels` TabPanel) and for\n * every one needing a provider or the canvas singleton — so it would need an\n * exemption list, and an exemption list that grows silently is worth less\n * than no test. See {@link auditChildContract} for what is enforced instead.\n */\n\nimport { FEATURE_FLAG } from '../foundation/constants/shared'\n\n/**\n * The slice of a component schema this contract reads. Structural on purpose:\n * `ComponentSchema` (types/nodes) and `AglynComponentSchema` (foundation) are\n * two spellings of the same thing and bundles are typed with both.\n */\nexport interface ChildContractSchema {\n $id?: string\n displayName?: string\n restrictChildren?: unknown\n flags?: {\n dropping?: FEATURE_FLAG\n selfClosing?: FEATURE_FLAG\n textEditable?: FEATURE_FLAG\n }\n}\n\n/** One registry entry — matches `MUI_BUNDLE` / `FeatureBundleEntry`. */\nexport interface ChildContractEntry {\n schema: ChildContractSchema\n}\n\n/**\n * True only when a feature flag is explicitly present and carries the ENABLED\n * bit. An absent (undefined) flag reads as false: components that declare no\n * flags at all (Stack, Section, the document root) must not be mistaken for\n * self-closing leaves.\n */\nexport function isLeafFlagEnabled(flag?: FEATURE_FLAG): boolean {\n return typeof flag === 'number' && (flag & FEATURE_FLAG.ENABLED) !== 0\n}\n\n/**\n * The mirror, for the flags whose interesting value is the switched-OFF one.\n * An absent flag reads as false, so a component that declares no flags keeps\n * the permissive default.\n */\nexport function isFlagDisabled(flag?: FEATURE_FLAG): boolean {\n return typeof flag === 'number' && (flag & FEATURE_FLAG.DISABLED) !== 0\n}\n\n/**\n * A `restrictChildren` that allows NOTHING — `[LIMIT_TO, { components: [] }]`,\n * the Layout Slot's spelling of \"my children come from somewhere else\".\n *\n * Read here as a fourth way to say \"no canvas child slot\", alongside the three\n * flags: the dnd manager's `confirmValidLinealRelationship` already refuses\n * every candidate against an empty allowlist, so a schema that says this and\n * nothing else was already unreachable by drop — the hierarchy just hadn't\n * been told. Deliberately narrow: an empty DISALLOW list forbids nothing, and\n * an allowlist naming plugins (rather than components) still admits those.\n */\nfunction restrictsChildrenToNothing(restrict: unknown): boolean {\n if (!Array.isArray(restrict) || restrict.length < 2) return false\n const [directive, definition] = restrict as [unknown, unknown]\n if (directive !== 'limitedTo') return false\n if (Array.isArray(definition)) return definition.length === 0\n if (!definition || typeof definition !== 'object') return false\n const { components, plugins } = definition as {\n components?: unknown\n plugins?: unknown\n }\n if (Array.isArray(plugins) && plugins.length > 0) return false\n return Array.isArray(components) && components.length === 0\n}\n\n/**\n * Whether the editor will let a node be dropped into a node of this component\n * — the ONE rule every author-facing entry point shares (the Insert menu and\n * paste via `resolveInsertTarget`, canvas drag-and-drop via the dnd manager's\n * `computedDrop`, and the drop indicator), read through\n * `CanvasManager.nodeAcceptsChildren`.\n *\n * Four ways a schema declares it has nowhere to put a dropped node:\n *\n * - `flags.selfClosing` — an image, an icon, a divider.\n * - `flags.textEditable` — the component renders `children` as editable text,\n * so an element dropped in would be destroyed by the next text edit.\n * - `flags.dropping: DISABLED` — neither of the above, but still no slot\n * (AGL-1388): Markdown renders its parsed `content` prop and nothing else;\n * a Reusable Component instance has its child list REPLACED by the grafted\n * definition at compose time; List Item Text hands `children` to MUI, which\n * reads it only as a fallback for a missing `primary`.\n * - an empty `restrictChildren` allowlist — see above.\n *\n * Anything else accepts children, which is why the audit below exists: the\n * permissive answer is the DEFAULT, so a component nobody thought about is\n * indistinguishable from a container until something asks.\n *\n * An unregistered / missing schema accepts children, matching the canvas.\n */\nexport function schemaAcceptsChildren(\n schema: ChildContractSchema | null | undefined,\n): boolean {\n if (!schema) return true\n const flags = schema.flags\n if (isFlagDisabled(flags?.dropping)) return false\n if (isLeafFlagEnabled(flags?.selfClosing)) return false\n if (isLeafFlagEnabled(flags?.textEditable)) return false\n return !restrictsChildrenToNothing(schema.restrictChildren)\n}\n\n/**\n * A bundle's declared containers: the component ids whose author has\n * confirmed the component renders the nodes dropped into it.\n *\n * Not an exemption list — the inverse. Exemptions are the entries a guard\n * agrees to skip, so they accumulate quietly and the guard shrinks. This list\n * is the set the guard is ABOUT: a new component defaults to accepting\n * children, so it lands here or the test is red, and the only way to make it\n * green is to answer the question (\"does it render `children`?\") one way or\n * the other. Nothing can be added by accident.\n */\nexport type DeclaredContainers = readonly string[]\n\n/**\n * Every disagreement between a bundle's schemas and its declared container\n * list, as reviewer-facing lines (empty = the contract holds).\n *\n * Both directions matter:\n *\n * - An undeclared container is the AGL-1388 shape arriving again — a\n * component the editor will accept a drop into that nobody has confirmed\n * renders one.\n * - A declared id that no longer accepts a drop, or is no longer registered,\n * is a stale entry. Left alone the list would slowly become a wish, and a\n * guard checked against a wish passes over anything.\n *\n * Returns strings rather than throwing so the caller is a one-line\n * `expect(...).toEqual([])` whose failure output names every offender at once\n * — the whole point being that the next person does not repeat the 82-schema\n * hand audit.\n */\nexport function auditChildContract(\n entries: readonly ChildContractEntry[],\n declared: DeclaredContainers,\n): string[] {\n const problems: string[] = []\n const declaredSet = new Set(declared)\n const registered = new Set<string>()\n\n for (const entry of entries) {\n const schema = entry?.schema\n const id = schema?.$id\n if (!id) {\n problems.push(\n `a schema in this bundle has no $id (${\n schema?.displayName ?? 'unnamed'\n }) — it cannot be held to the drop/render agreement`,\n )\n continue\n }\n registered.add(id)\n const accepts = schemaAcceptsChildren(schema)\n if (accepts && !declaredSet.has(id)) {\n problems.push(\n `${id} accepts a drop but is not a declared container — either it ` +\n 'renders its children (add it to the list) or it does not, and ' +\n 'then it must SAY so: flags.selfClosing, flags.textEditable, ' +\n 'flags.dropping: FEATURE_FLAG.DISABLED, or an empty ' +\n 'restrictChildren allowlist (AGL-1389)',\n )\n }\n if (!accepts && declaredSet.has(id)) {\n problems.push(\n `${id} is a declared container but the editor now refuses drops ` +\n 'into it — drop it from the list, or remove whatever flag closed ' +\n 'it (AGL-1389)',\n )\n }\n }\n\n for (const id of declaredSet) {\n if (!registered.has(id)) {\n problems.push(\n `${id} is a declared container but is not registered in this ` +\n 'bundle — remove the stale entry (AGL-1389)',\n )\n }\n }\n\n return problems.sort()\n}\n\n/** The ids in a bundle the editor will accept a drop into, sorted. */\nexport function listAcceptingComponentIds(\n entries: readonly ChildContractEntry[],\n): string[] {\n return entries\n .filter((entry) => schemaAcceptsChildren(entry?.schema))\n .map((entry) => entry.schema.$id as string)\n .filter(Boolean)\n .sort()\n}\n"],"names":["FEATURE_FLAG","isLeafFlagEnabled","flag","ENABLED","isFlagDisabled","DISABLED","restrictsChildrenToNothing","restrict","Array","isArray","length","directive","definition","components","plugins","schemaAcceptsChildren","schema","flags","dropping","selfClosing","textEditable","restrictChildren","auditChildContract","entries","declared","problems","declaredSet","Set","registered","entry","id","$id","push","displayName","add","accepts","has","sort","listAcceptingComponentIds","filter","map","Boolean"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/child-contract.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\n/**\n * The drop/render agreement (AGL-1389).\n *\n * A component that accepts a drop in the hierarchy and discards the node at\n * render is silent data loss: the author sees the node in the tree, the\n * published page does not have it, and nothing warns — so the work reads as\n * *never done* rather than broken. Three /press screenshots shipped in a page\n * payload for weeks that way (AGL-1388).\n *\n * That was found by reading all 82 registered schemas by hand, one at a time.\n * This module is the repeatable half: the predicate the editor actually gates\n * drops on, plus an audit that turns \"someone added a component and never\n * thought about children\" into a red test.\n *\n * Deliberately NOT a render sweep. Rendering every schema with a sentinel\n * child fails for components that legitimately hide children behind state\n * (a collapsed Accordion, a closed Drawer, a `lazyPanels` TabPanel) and for\n * every one needing a provider or the canvas singleton — so it would need an\n * exemption list, and an exemption list that grows silently is worth less\n * than no test. See {@link auditChildContract} for what is enforced instead.\n */\n\nimport { FEATURE_FLAG } from '../foundation/constants/shared'\n\n/**\n * The slice of a component schema this contract reads. Structural on purpose:\n * `ComponentSchema` (types/nodes) and `AglynComponentSchema` (foundation) are\n * two spellings of the same thing and bundles are typed with both.\n */\nexport interface ChildContractSchema {\n $id?: string\n displayName?: string\n restrictChildren?: unknown\n flags?: {\n dropping?: FEATURE_FLAG\n selfClosing?: FEATURE_FLAG\n textEditable?: FEATURE_FLAG\n }\n}\n\n/** One registry entry — matches `MUI_BUNDLE` / `FeatureBundleEntry`. */\nexport interface ChildContractEntry {\n schema: ChildContractSchema\n}\n\n/**\n * True only when a feature flag is explicitly present and carries the ENABLED\n * bit. An absent (undefined) flag reads as false: components that declare no\n * flags at all (Stack, Section, the document root) must not be mistaken for\n * self-closing leaves.\n */\nexport function isLeafFlagEnabled(flag?: FEATURE_FLAG): boolean {\n return typeof flag === 'number' && (flag & FEATURE_FLAG.ENABLED) !== 0\n}\n\n/**\n * The mirror, for the flags whose interesting value is the switched-OFF one.\n * An absent flag reads as false, so a component that declares no flags keeps\n * the permissive default.\n */\nexport function isFlagDisabled(flag?: FEATURE_FLAG): boolean {\n return typeof flag === 'number' && (flag & FEATURE_FLAG.DISABLED) !== 0\n}\n\n/**\n * A `restrictChildren` that allows NOTHING — `[LIMIT_TO, { components: [] }]`,\n * the Layout Slot's spelling of \"my children come from somewhere else\".\n *\n * Read here as a fourth way to say \"no canvas child slot\", alongside the three\n * flags: the dnd manager's `confirmValidLinealRelationship` already refuses\n * every candidate against an empty allowlist, so a schema that says this and\n * nothing else was already unreachable by drop — the hierarchy just hadn't\n * been told. Deliberately narrow: an empty DISALLOW list forbids nothing, and\n * an allowlist naming plugins (rather than components) still admits those.\n */\nfunction restrictsChildrenToNothing(restrict: unknown): boolean {\n if (!Array.isArray(restrict) || restrict.length < 2) return false\n const [directive, definition] = restrict as [unknown, unknown]\n if (directive !== 'limitedTo') return false\n if (Array.isArray(definition)) return definition.length === 0\n if (!definition || typeof definition !== 'object') return false\n const { components, plugins } = definition as {\n components?: unknown\n plugins?: unknown\n }\n if (Array.isArray(plugins) && plugins.length > 0) return false\n return Array.isArray(components) && components.length === 0\n}\n\n/**\n * Whether the editor will let a node be dropped into a node of this component\n * — the ONE rule every author-facing entry point shares (the Insert menu and\n * paste via `resolveInsertTarget`, canvas drag-and-drop via the dnd manager's\n * `computedDrop`, and the drop indicator), read through\n * `CanvasManager.nodeAcceptsChildren`.\n *\n * Four ways a schema declares it has nowhere to put a dropped node:\n *\n * - `flags.selfClosing` — an image, an icon, a divider.\n * - `flags.textEditable` — the component renders `children` as editable text,\n * so an element dropped in would be destroyed by the next text edit —\n * unless the schema also says `flags.dropping: ENABLED`. That is a promise\n * the component keeps its text in its own `<aglyn-text>` beside the child\n * elements, which is the only part the in-place editor rewrites (AGL-3672).\n * - `flags.dropping: DISABLED` — neither of the above, but still no slot\n * (AGL-1388): Markdown renders its parsed `content` prop and nothing else;\n * a Reusable Component instance has its child list REPLACED by the grafted\n * definition at compose time; List Item Text hands `children` to MUI, which\n * reads it only as a fallback for a missing `primary`.\n * - an empty `restrictChildren` allowlist — see above.\n *\n * Anything else accepts children, which is why the audit below exists: the\n * permissive answer is the DEFAULT, so a component nobody thought about is\n * indistinguishable from a container until something asks.\n *\n * An unregistered / missing schema accepts children, matching the canvas.\n */\nexport function schemaAcceptsChildren(\n schema: ChildContractSchema | null | undefined,\n): boolean {\n if (!schema) return true\n const flags = schema.flags\n if (isFlagDisabled(flags?.dropping)) return false\n if (isLeafFlagEnabled(flags?.selfClosing)) return false\n if (\n isLeafFlagEnabled(flags?.textEditable) &&\n !isLeafFlagEnabled(flags?.dropping)\n )\n return false\n return !restrictsChildrenToNothing(schema.restrictChildren)\n}\n\n/**\n * A bundle's declared containers: the component ids whose author has\n * confirmed the component renders the nodes dropped into it.\n *\n * Not an exemption list — the inverse. Exemptions are the entries a guard\n * agrees to skip, so they accumulate quietly and the guard shrinks. This list\n * is the set the guard is ABOUT: a new component defaults to accepting\n * children, so it lands here or the test is red, and the only way to make it\n * green is to answer the question (\"does it render `children`?\") one way or\n * the other. Nothing can be added by accident.\n */\nexport type DeclaredContainers = readonly string[]\n\n/**\n * Every disagreement between a bundle's schemas and its declared container\n * list, as reviewer-facing lines (empty = the contract holds).\n *\n * Both directions matter:\n *\n * - An undeclared container is the AGL-1388 shape arriving again — a\n * component the editor will accept a drop into that nobody has confirmed\n * renders one.\n * - A declared id that no longer accepts a drop, or is no longer registered,\n * is a stale entry. Left alone the list would slowly become a wish, and a\n * guard checked against a wish passes over anything.\n *\n * Returns strings rather than throwing so the caller is a one-line\n * `expect(...).toEqual([])` whose failure output names every offender at once\n * — the whole point being that the next person does not repeat the 82-schema\n * hand audit.\n */\nexport function auditChildContract(\n entries: readonly ChildContractEntry[],\n declared: DeclaredContainers,\n): string[] {\n const problems: string[] = []\n const declaredSet = new Set(declared)\n const registered = new Set<string>()\n\n for (const entry of entries) {\n const schema = entry?.schema\n const id = schema?.$id\n if (!id) {\n problems.push(\n `a schema in this bundle has no $id (${\n schema?.displayName ?? 'unnamed'\n }) — it cannot be held to the drop/render agreement`,\n )\n continue\n }\n registered.add(id)\n const accepts = schemaAcceptsChildren(schema)\n if (accepts && !declaredSet.has(id)) {\n problems.push(\n `${id} accepts a drop but is not a declared container — either it ` +\n 'renders its children (add it to the list) or it does not, and ' +\n 'then it must SAY so: flags.selfClosing, flags.textEditable, ' +\n 'flags.dropping: FEATURE_FLAG.DISABLED, or an empty ' +\n 'restrictChildren allowlist (AGL-1389)',\n )\n }\n if (!accepts && declaredSet.has(id)) {\n problems.push(\n `${id} is a declared container but the editor now refuses drops ` +\n 'into it — drop it from the list, or remove whatever flag closed ' +\n 'it (AGL-1389)',\n )\n }\n }\n\n for (const id of declaredSet) {\n if (!registered.has(id)) {\n problems.push(\n `${id} is a declared container but is not registered in this ` +\n 'bundle — remove the stale entry (AGL-1389)',\n )\n }\n }\n\n return problems.sort()\n}\n\n/** The ids in a bundle the editor will accept a drop into, sorted. */\nexport function listAcceptingComponentIds(\n entries: readonly ChildContractEntry[],\n): string[] {\n return entries\n .filter((entry) => schemaAcceptsChildren(entry?.schema))\n .map((entry) => entry.schema.$id as string)\n .filter(Boolean)\n .sort()\n}\n"],"names":["FEATURE_FLAG","isLeafFlagEnabled","flag","ENABLED","isFlagDisabled","DISABLED","restrictsChildrenToNothing","restrict","Array","isArray","length","directive","definition","components","plugins","schemaAcceptsChildren","schema","flags","dropping","selfClosing","textEditable","restrictChildren","auditChildContract","entries","declared","problems","declaredSet","Set","registered","entry","id","$id","push","displayName","add","accepts","has","sort","listAcceptingComponentIds","filter","map","Boolean"],"mappings":"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"}
@@ -120,6 +120,7 @@ export declare enum Route {
120
120
  HOST_SETUP_SEO = "/[orgSlug]/hosts/[host]/setup/seo",
121
121
  HOST_SETUP_TRACKING = "/[orgSlug]/hosts/[host]/setup/tracking",
122
122
  HOST_SETUP_THEME = "/[orgSlug]/hosts/[host]/setup/theme",
123
+ HOST_SETUP_BUSINESS = "/[orgSlug]/hosts/[host]/setup/business",
123
124
  HOST_SETUP_EMAILS = "/[orgSlug]/hosts/[host]/setup/emails",
124
125
  HOST_ADMIN = "/[orgSlug]/hosts/[host]/admin",
125
126
  HOST_ADMIN_GENERAL = "/[orgSlug]/hosts/[host]/admin/general",
@@ -414,6 +415,10 @@ export interface RoutePayload {
414
415
  orgSlug: string;
415
416
  host: string;
416
417
  };
418
+ [Route.HOST_SETUP_BUSINESS]: {
419
+ orgSlug: string;
420
+ host: string;
421
+ };
417
422
  [Route.HOST_SETUP_EMAILS]: {
418
423
  orgSlug: string;
419
424
  host: string;
@@ -380,6 +380,7 @@ export var Route = /*#__PURE__*/ function(Route) {
380
380
  Route["HOST_SETUP_SEO"] = "/[orgSlug]/hosts/[host]/setup/seo";
381
381
  Route["HOST_SETUP_TRACKING"] = "/[orgSlug]/hosts/[host]/setup/tracking";
382
382
  Route["HOST_SETUP_THEME"] = "/[orgSlug]/hosts/[host]/setup/theme";
383
+ Route["HOST_SETUP_BUSINESS"] = "/[orgSlug]/hosts/[host]/setup/business";
383
384
  Route["HOST_SETUP_EMAILS"] = "/[orgSlug]/hosts/[host]/setup/emails";
384
385
  // Host Admin area (AGL-1014): owner/admin-only controls — per-site plugin
385
386
  // enablement and the Danger zone — out of the Setup page collaborators