@vectoriox/iox-ui 5.54.0 → 5.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +1279 -55
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs +477 -30
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
- package/generation-core/index.d.ts +523 -4
- package/harness-core/index.d.ts +315 -16
- package/package.json +1 -1
- package/src/generation-core/ai-run.ts +17 -2
- package/src/generation-core/audit-site.ts +386 -0
- package/src/generation-core/json-schema.ts +97 -0
- package/src/generation-core/plan-edits.ts +428 -0
- package/src/generation-core/plan-words.ts +114 -0
- package/src/generation-core/public-api.ts +6 -0
- package/src/generation-core/site-build-run.ts +7 -0
- package/src/generation-core/site-plan-schema.ts +239 -0
- package/src/generation-core/stock-photos.ts +137 -0
- package/src/harness-core/generation-view.ts +115 -0
- package/src/harness-core/palette.ts +198 -0
- package/src/harness-core/public-api.ts +5 -0
- package/src/harness-core/reference-colour.ts +45 -0
- package/src/harness-core/site-analysis.ts +8 -29
- package/src/harness-core/site-reading.ts +214 -0
- package/src/harness-core/starting-design.ts +44 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, elementStyleById, ownedTraitsOf, applyElementStyle, flavorPackById, parseHexColor, SPEC_AXES, LinkType, COLOR_SCHEMES, setColorScheme, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
2
|
-
import { formPresetById } from '@vectoriox/iox-ui/forms-core';
|
|
1
|
+
import { NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, elementStyleById, ownedTraitsOf, applyElementStyle, flavorPackById, parseHexColor, SPEC_AXES, LinkType, COLOR_SCHEMES, setColorScheme, resolveDesignChoices, auditDesignContrast } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
import { formPresetById, ControlType, FORM_NODE_TYPE, collectFormSpec, FIELD_NODE_TYPE } from '@vectoriox/iox-ui/forms-core';
|
|
3
3
|
import { segmentedOptions, ITEM_OPEN_STATE, CardRole, CardLayout, CARD_LAYOUTS, CARD_PARTS, BADGE_TONES, buildCardRecipe, findByRole, buildSegmentedRecipe, AccordionRole, buildAccordionRecipe, ACCORDION_WORDS, accordionItems, accordionQuestionOf, accordionAnswerOf, ACCORDION_BIND_PROP, pickAccordionAutoBinds, ACCORDION_CONTENT_LIMIT, buildTabsRecipe, TABS_WORDS, tabsTriggers, tabLabelNodeOf, TABS_LABEL_BIND_PROP, tabsPanels, tabPanelTextOf, TABS_TEXT_BIND_PROP, tabPanelImageOf, pickTabsAutoBinds, tabsListOf, tabsPanelsOf, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, buildCarouselRecipe, SLIDE_TYPE, buildLightboxRecipe, LIGHTBOX_LABEL, SLIDE_TO_ROW } from '@vectoriox/iox-ui/composite-core';
|
|
4
|
+
import { ELEMENT_ACTION_DESCRIPTORS, resolveActionAvailability } from '@vectoriox/iox-ui/actions-core';
|
|
4
5
|
|
|
5
6
|
// GENERATED from iox-ai-guidance flavor-spec.v1.json — do not edit; re-run scripts/gen-flavor-spec.mjs
|
|
6
7
|
// (npm run gen:flavor-spec). The JSON is itself generated by iox-ai-guidance
|
|
@@ -7567,7 +7568,7 @@ var SiteTemplateId;
|
|
|
7567
7568
|
const STUDIO_TEMPLATE = SITE_TEMPLATES[SiteTemplateId.Studio];
|
|
7568
7569
|
class SiteTemplateError extends Error {
|
|
7569
7570
|
}
|
|
7570
|
-
const clone$
|
|
7571
|
+
const clone$4 = (value) => JSON.parse(JSON.stringify(value));
|
|
7571
7572
|
/**
|
|
7572
7573
|
* The template as a Site Plan (`source.kind: template`), with the customer's business and look. A
|
|
7573
7574
|
* required page cannot be left out, and an unknown key is refused — nothing is guessed.
|
|
@@ -7584,12 +7585,12 @@ function planFromTemplate(template, input) {
|
|
|
7584
7585
|
return {
|
|
7585
7586
|
version: SITE_PLAN_VERSION,
|
|
7586
7587
|
source: { kind: SitePlanSourceKind.Template, templateId: template.id, templateVersion: template.version },
|
|
7587
|
-
business: clone$
|
|
7588
|
-
design: clone$
|
|
7589
|
-
contentTypes: clone$
|
|
7590
|
-
dataSources: clone$
|
|
7591
|
-
pages: clone$
|
|
7592
|
-
globals: clone$
|
|
7588
|
+
business: clone$4(input.business),
|
|
7589
|
+
design: clone$4(input.design),
|
|
7590
|
+
contentTypes: clone$4(template.contentTypes),
|
|
7591
|
+
dataSources: clone$4(template.dataSources),
|
|
7592
|
+
pages: clone$4(template.pages.filter(p => !omit.has(p.key))),
|
|
7593
|
+
globals: clone$4(template.globals),
|
|
7593
7594
|
};
|
|
7594
7595
|
}
|
|
7595
7596
|
|
|
@@ -7598,7 +7599,7 @@ function planFromTemplate(template, input) {
|
|
|
7598
7599
|
* wrong — never something to work around. */
|
|
7599
7600
|
class ComposeError extends Error {
|
|
7600
7601
|
}
|
|
7601
|
-
const clone$
|
|
7602
|
+
const clone$3 = (value) => JSON.parse(JSON.stringify(value));
|
|
7602
7603
|
/**
|
|
7603
7604
|
* A NEW node of `type`, as the builder creates one (ComponentRegistryService.createConfig, serialized):
|
|
7604
7605
|
* the type's constructor defaults, its text colour released to `inherit` (NEW_NODE_STYLE_DEFAULTS), then
|
|
@@ -7613,8 +7614,8 @@ function createSerializedNode(id, type, props = {}, elementStyle, options = {})
|
|
|
7613
7614
|
const node = {
|
|
7614
7615
|
id,
|
|
7615
7616
|
type,
|
|
7616
|
-
props: { ...clone$
|
|
7617
|
-
styleProps: clone$
|
|
7617
|
+
props: { ...clone$3(baseline.props), ...props },
|
|
7618
|
+
styleProps: clone$3(baseline.styleProps),
|
|
7618
7619
|
bindings: [],
|
|
7619
7620
|
interactions: [],
|
|
7620
7621
|
};
|
|
@@ -7665,16 +7666,16 @@ function walkSerialized(nodes, visit, ancestors = []) {
|
|
|
7665
7666
|
* own field names) whose key lists the gate READS — so the shape a model is told about is the shape refused —
|
|
7666
7667
|
* and a collector that records each refusal at its dotted path. Generic over each gate's issue-code enum.
|
|
7667
7668
|
*/
|
|
7668
|
-
const OPTIONAL_MARK = '?';
|
|
7669
|
+
const OPTIONAL_MARK$1 = '?';
|
|
7669
7670
|
const DYNAMIC_KEY = /^\[[^\]]+\]\??$/;
|
|
7670
7671
|
const isDynamic = (key) => DYNAMIC_KEY.test(key);
|
|
7671
7672
|
/** Every fixed key an object of the shape may carry. */
|
|
7672
7673
|
function shapeKeys(entry) {
|
|
7673
|
-
return Object.keys(entry).filter(k => !isDynamic(k)).map(k => (k.endsWith(OPTIONAL_MARK) ? k.slice(0, -1) : k));
|
|
7674
|
+
return Object.keys(entry).filter(k => !isDynamic(k)).map(k => (k.endsWith(OPTIONAL_MARK$1) ? k.slice(0, -1) : k));
|
|
7674
7675
|
}
|
|
7675
7676
|
/** The keys an object of the shape must carry. */
|
|
7676
7677
|
function requiredShapeKeys(entry) {
|
|
7677
|
-
return Object.keys(entry).filter(k => !isDynamic(k) && !k.endsWith(OPTIONAL_MARK));
|
|
7678
|
+
return Object.keys(entry).filter(k => !isDynamic(k) && !k.endsWith(OPTIONAL_MARK$1));
|
|
7678
7679
|
}
|
|
7679
7680
|
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
7680
7681
|
const isNonEmptyString = (v) => typeof v === 'string' && v.trim() !== '';
|
|
@@ -7729,6 +7730,83 @@ class PathIssueCollector {
|
|
|
7729
7730
|
}
|
|
7730
7731
|
}
|
|
7731
7732
|
|
|
7733
|
+
const OPTIONAL_MARK = '?';
|
|
7734
|
+
class JsonSchemaShapeError extends Error {
|
|
7735
|
+
}
|
|
7736
|
+
const jsonText = () => ({ type: 'string' });
|
|
7737
|
+
const jsonBoolean = () => ({ type: 'boolean' });
|
|
7738
|
+
const jsonInteger = () => ({ type: 'integer' });
|
|
7739
|
+
const jsonOneOf = (values) => ({ type: 'string', enum: [...values] });
|
|
7740
|
+
const jsonListOf = (items) => ({ type: 'array', items });
|
|
7741
|
+
const jsonAnyOf = (options) => ({ anyOf: [...options] });
|
|
7742
|
+
/** A closed object with exactly these properties. */
|
|
7743
|
+
function jsonClosedObject(properties, required, description) {
|
|
7744
|
+
return {
|
|
7745
|
+
type: 'object',
|
|
7746
|
+
properties,
|
|
7747
|
+
required: [...required],
|
|
7748
|
+
additionalProperties: false,
|
|
7749
|
+
...(description ? { description } : {}),
|
|
7750
|
+
};
|
|
7751
|
+
}
|
|
7752
|
+
/** The table's own words for a key (`key` or `key?`). */
|
|
7753
|
+
function describeShapeKey(entry, key) {
|
|
7754
|
+
return String(entry[key] ?? entry[`${key}${OPTIONAL_MARK}`] ?? '');
|
|
7755
|
+
}
|
|
7756
|
+
/**
|
|
7757
|
+
* An object of one shape-table entry: exactly its fixed keys, its required keys required, each described by the
|
|
7758
|
+
* table; an entry's `[name]` keys are not object keys — `dynamic` writes them as one list. Throws when the properties
|
|
7759
|
+
* given are not the table's keys.
|
|
7760
|
+
*/
|
|
7761
|
+
function jsonShapedObject(entry, properties, name = 'shape', dynamic) {
|
|
7762
|
+
const expected = [...shapeKeys(entry)].sort();
|
|
7763
|
+
const given = Object.keys(properties).sort();
|
|
7764
|
+
if (expected.join() !== given.join()) {
|
|
7765
|
+
throw new JsonSchemaShapeError(`${name}: the format's keys (${given.join(', ')}) are not the shape table's (${expected.join(', ')})`);
|
|
7766
|
+
}
|
|
7767
|
+
const dynamicKey = Object.keys(entry).find(k => /^\[[^\]]+\]\??$/.test(k));
|
|
7768
|
+
if (!!dynamic !== !!dynamicKey)
|
|
7769
|
+
throw new JsonSchemaShapeError(`${name}: ${dynamicKey ? `its ${dynamicKey} keys need a list on the wire` : 'it has no [name] keys to list'}`);
|
|
7770
|
+
const described = Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, { ...schema, description: describeShapeKey(entry, key) }]));
|
|
7771
|
+
if (dynamic && dynamicKey)
|
|
7772
|
+
described[dynamic.key] = { ...dynamic.schema, description: `${dynamicKey}: ${entry[dynamicKey]}` };
|
|
7773
|
+
return jsonClosedObject(described, requiredShapeKeys(entry));
|
|
7774
|
+
}
|
|
7775
|
+
/** Every keyword a schema uses — what a spec checks against the strict subset. */
|
|
7776
|
+
function jsonSchemaKeywords(node, out = new Set()) {
|
|
7777
|
+
if (Array.isArray(node))
|
|
7778
|
+
node.forEach(n => jsonSchemaKeywords(n, out));
|
|
7779
|
+
else if (node && typeof node === 'object') {
|
|
7780
|
+
for (const [k, v] of Object.entries(node)) {
|
|
7781
|
+
out.add(k);
|
|
7782
|
+
if (k === 'properties')
|
|
7783
|
+
Object.values(v).forEach(p => jsonSchemaKeywords(p, out));
|
|
7784
|
+
else if (k !== 'enum' && k !== 'required' && k !== 'description')
|
|
7785
|
+
jsonSchemaKeywords(v, out);
|
|
7786
|
+
}
|
|
7787
|
+
}
|
|
7788
|
+
return out;
|
|
7789
|
+
}
|
|
7790
|
+
/** The keywords the strict subset never uses. */
|
|
7791
|
+
const JSON_SCHEMA_BANNED_KEYWORDS = [
|
|
7792
|
+
'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'pattern', 'oneOf', '$ref', 'const', 'format',
|
|
7793
|
+
];
|
|
7794
|
+
/** Every object node of a schema (through properties, items and anyOf). */
|
|
7795
|
+
function jsonSchemaObjects(node, out = []) {
|
|
7796
|
+
if (!node || typeof node !== 'object')
|
|
7797
|
+
return out;
|
|
7798
|
+
const n = node;
|
|
7799
|
+
if (n['type'] === 'object')
|
|
7800
|
+
out.push(n);
|
|
7801
|
+
for (const p of Object.values(n['properties'] ?? {}))
|
|
7802
|
+
jsonSchemaObjects(p, out);
|
|
7803
|
+
if (n['items'])
|
|
7804
|
+
jsonSchemaObjects(n['items'], out);
|
|
7805
|
+
for (const option of n['anyOf'] ?? [])
|
|
7806
|
+
jsonSchemaObjects(option, out);
|
|
7807
|
+
return out;
|
|
7808
|
+
}
|
|
7809
|
+
|
|
7732
7810
|
/**
|
|
7733
7811
|
* The Site Plan validator — the gate in front of the composer. The wizard's plan and (later) the AI's
|
|
7734
7812
|
* plan go through the SAME checks: an unknown pattern, a slot the pattern does not declare, a source
|
|
@@ -7776,20 +7854,20 @@ const RESERVED_PREFIX = '$';
|
|
|
7776
7854
|
const ID_FIELD = '_id';
|
|
7777
7855
|
/** The documented plan shape (contract `site.planShape`): its key lists are READ (`shape-check`), so a key
|
|
7778
7856
|
* the plan documents is a key the gate validates — and an undocumented one is refused. */
|
|
7779
|
-
const SHAPE = SPEC_SITE_PLAN_SHAPE;
|
|
7780
|
-
const PLAN_KEYS = shapeKeys(SHAPE['plan']);
|
|
7781
|
-
const TEMPLATE_SOURCE_KEYS = shapeKeys(SHAPE['sourceTemplate']);
|
|
7782
|
-
const AI_SOURCE_KEYS = shapeKeys(SHAPE['sourceAi']);
|
|
7783
|
-
const BUSINESS_KEYS = shapeKeys(SHAPE['business']);
|
|
7784
|
-
const DESIGN_REQUIRED = requiredShapeKeys(SHAPE['design']);
|
|
7785
|
-
const DESIGN_KEYS = shapeKeys(SHAPE['design']);
|
|
7786
|
-
const CONTENT_TYPE_KEYS = shapeKeys(SHAPE['contentType']);
|
|
7787
|
-
const FIELD_KEYS = shapeKeys(SHAPE['field']);
|
|
7788
|
-
const SEED_FIXED_KEYS = shapeKeys(SHAPE['seed']);
|
|
7789
|
-
const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
|
|
7790
|
-
const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE['relativeDataSource']);
|
|
7791
|
-
const PAGE_KEYS = shapeKeys(SHAPE['page']);
|
|
7792
|
-
const SECTION_KEYS = shapeKeys(SHAPE['section']);
|
|
7857
|
+
const SHAPE$1 = SPEC_SITE_PLAN_SHAPE;
|
|
7858
|
+
const PLAN_KEYS = shapeKeys(SHAPE$1['plan']);
|
|
7859
|
+
const TEMPLATE_SOURCE_KEYS = shapeKeys(SHAPE$1['sourceTemplate']);
|
|
7860
|
+
const AI_SOURCE_KEYS = shapeKeys(SHAPE$1['sourceAi']);
|
|
7861
|
+
const BUSINESS_KEYS = shapeKeys(SHAPE$1['business']);
|
|
7862
|
+
const DESIGN_REQUIRED = requiredShapeKeys(SHAPE$1['design']);
|
|
7863
|
+
const DESIGN_KEYS = shapeKeys(SHAPE$1['design']);
|
|
7864
|
+
const CONTENT_TYPE_KEYS = shapeKeys(SHAPE$1['contentType']);
|
|
7865
|
+
const FIELD_KEYS = shapeKeys(SHAPE$1['field']);
|
|
7866
|
+
const SEED_FIXED_KEYS = shapeKeys(SHAPE$1['seed']);
|
|
7867
|
+
const CMS_SOURCE_KEYS = shapeKeys(SHAPE$1['cmsDataSource']);
|
|
7868
|
+
const RELATIVE_SOURCE_KEYS = shapeKeys(SHAPE$1['relativeDataSource']);
|
|
7869
|
+
const PAGE_KEYS = shapeKeys(SHAPE$1['page']);
|
|
7870
|
+
const SECTION_KEYS = shapeKeys(SHAPE$1['section']);
|
|
7793
7871
|
const GLOBAL_POSITION = {
|
|
7794
7872
|
[GlobalSlot.Header]: PatternGlobalPosition.Before,
|
|
7795
7873
|
[GlobalSlot.Footer]: PatternGlobalPosition.After,
|
|
@@ -7801,11 +7879,11 @@ const has = (table, key) => Object.prototype.hasOwnProperty.call(table, key);
|
|
|
7801
7879
|
function routeParams(route) {
|
|
7802
7880
|
return [...route.matchAll(ROUTE_PARAM$1)].map(m => m[1]);
|
|
7803
7881
|
}
|
|
7804
|
-
class Collector extends PathIssueCollector {
|
|
7882
|
+
let Collector$1 = class Collector extends PathIssueCollector {
|
|
7805
7883
|
constructor() {
|
|
7806
7884
|
super('plan', { shape: SitePlanIssueCode.Shape, unknownKey: SitePlanIssueCode.UnknownKey, duplicate: SitePlanIssueCode.Duplicate });
|
|
7807
7885
|
}
|
|
7808
|
-
}
|
|
7886
|
+
};
|
|
7809
7887
|
function validateSource(c, source) {
|
|
7810
7888
|
if (!isPlainObject(source))
|
|
7811
7889
|
return c.add(SitePlanIssueCode.Shape, 'source', 'must be an object');
|
|
@@ -8143,7 +8221,7 @@ function validateGlobals(c, globals) {
|
|
|
8143
8221
|
}
|
|
8144
8222
|
/** Check a Site Plan against the plan shape and the pattern library. Never throws, never repairs. */
|
|
8145
8223
|
function validateSitePlan(plan) {
|
|
8146
|
-
const c = new Collector();
|
|
8224
|
+
const c = new Collector$1();
|
|
8147
8225
|
if (!isPlainObject(plan)) {
|
|
8148
8226
|
c.add(SitePlanIssueCode.Shape, '', 'a site plan is an object');
|
|
8149
8227
|
return { valid: false, issues: c.issues };
|
|
@@ -8506,7 +8584,7 @@ function bar(kit, root, pages, shape, count, drawerId, at) {
|
|
|
8506
8584
|
return b;
|
|
8507
8585
|
}
|
|
8508
8586
|
// ── the edit ────────────────────────────────────────────────────────────────────────────────
|
|
8509
|
-
const clone$
|
|
8587
|
+
const clone$2 = (v) => JSON.parse(JSON.stringify(v));
|
|
8510
8588
|
function removeRole(node, role) {
|
|
8511
8589
|
let removed = null;
|
|
8512
8590
|
const visit = (n) => {
|
|
@@ -8558,7 +8636,7 @@ function clampBarCount(n, shape) {
|
|
|
8558
8636
|
function planNavShape(globals, shape, options = {}, navId) {
|
|
8559
8637
|
if (!Object.values(NavShape).includes(shape))
|
|
8560
8638
|
throw new NavShapeError(`unknown phone menu shape "${shape}"`);
|
|
8561
|
-
const out = clone$
|
|
8639
|
+
const out = clone$2(globals);
|
|
8562
8640
|
const root = findNavRoot(out, navId);
|
|
8563
8641
|
if (!root)
|
|
8564
8642
|
throw new NavShapeError('no Navigation bar among the globals');
|
|
@@ -8678,7 +8756,7 @@ function nameParts(root) {
|
|
|
8678
8756
|
* bar does not link to yet (Home when every page is there). Refused at five (Q2). Input not mutated.
|
|
8679
8757
|
*/
|
|
8680
8758
|
function addBarItem(globals, options = {}) {
|
|
8681
|
-
const out = clone$
|
|
8759
|
+
const out = clone$2(globals);
|
|
8682
8760
|
const root = findNavRoot(out);
|
|
8683
8761
|
const b = find(out, NavRole.Bar);
|
|
8684
8762
|
if (!root || !b)
|
|
@@ -8698,7 +8776,7 @@ function addBarItem(globals, options = {}) {
|
|
|
8698
8776
|
return { globals: out, added: true };
|
|
8699
8777
|
}
|
|
8700
8778
|
function barOf(globals) {
|
|
8701
|
-
const out = clone$
|
|
8779
|
+
const out = clone$2(globals);
|
|
8702
8780
|
const b = find(out, NavRole.Bar);
|
|
8703
8781
|
return { out, kids: b ? (b.children ?? (b.children = [])) : null };
|
|
8704
8782
|
}
|
|
@@ -8839,7 +8917,7 @@ const COMPOSED = { releaseLayoutDefaults: true };
|
|
|
8839
8917
|
const TEXT_VARIABLE = /\{(year|business\.name)\}/g;
|
|
8840
8918
|
/** What `page.*` may read on a node stamped for a navigation page. */
|
|
8841
8919
|
const STAMPED_PAGE_FIELDS = new Set(['name', 'route']);
|
|
8842
|
-
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
8920
|
+
const clone$1 = (value) => JSON.parse(JSON.stringify(value));
|
|
8843
8921
|
/** 32-bit FNV-1a, base 36 — a short stable id fragment. */
|
|
8844
8922
|
function stableHash(text) {
|
|
8845
8923
|
let h = 0x811c9dc5;
|
|
@@ -8850,7 +8928,7 @@ function stableHash(text) {
|
|
|
8850
8928
|
return h.toString(36).padStart(7, '0').slice(-6);
|
|
8851
8929
|
}
|
|
8852
8930
|
const create = (id, type, props = {}, elementStyle) => createSerializedNode(id, type, props, elementStyle, COMPOSED);
|
|
8853
|
-
function nonEmpty(v) {
|
|
8931
|
+
function nonEmpty$1(v) {
|
|
8854
8932
|
return typeof v === 'string' && v.trim() !== '';
|
|
8855
8933
|
}
|
|
8856
8934
|
/** A pattern's own words with the compose-time variables filled: `{year}`, `{business.name}`. */
|
|
@@ -8862,16 +8940,16 @@ function fillVariables(text, ctx) {
|
|
|
8862
8940
|
function chooseText(pn, ctx) {
|
|
8863
8941
|
const fact = pn.ph && FACT_KEYS.includes(pn.ph) ? pn.ph : null;
|
|
8864
8942
|
const slotValue = pn.slot ? ctx.section?.slots?.[pn.slot] : undefined;
|
|
8865
|
-
if (nonEmpty(slotValue))
|
|
8943
|
+
if (nonEmpty$1(slotValue))
|
|
8866
8944
|
return { value: slotValue };
|
|
8867
8945
|
if (pn.fromBusiness) {
|
|
8868
8946
|
const v = ctx.plan.business[pn.fromBusiness];
|
|
8869
|
-
if (nonEmpty(v))
|
|
8947
|
+
if (nonEmpty$1(v))
|
|
8870
8948
|
return { value: v };
|
|
8871
8949
|
}
|
|
8872
8950
|
if (fact) {
|
|
8873
8951
|
const given = ctx.plan.business.facts?.[fact];
|
|
8874
|
-
return nonEmpty(given) ? { value: given } : { value: SITE_FACT_LABELS[fact], placeholder: { kind: PlaceholderKind.Fact, fact } };
|
|
8952
|
+
return nonEmpty$1(given) ? { value: given } : { value: SITE_FACT_LABELS[fact], placeholder: { kind: PlaceholderKind.Fact, fact } };
|
|
8875
8953
|
}
|
|
8876
8954
|
const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
|
|
8877
8955
|
if (pn.ph === PatternPlaceholder.Copy)
|
|
@@ -9579,7 +9657,7 @@ function composeSite(plan, context) {
|
|
|
9579
9657
|
const issues = [];
|
|
9580
9658
|
const routes = new Set(plan.pages.map(p => p.route));
|
|
9581
9659
|
const relatives = new Map(plan.dataSources.map(d => [d.alias, d.type === PlanDataSourceType.Relative ? { type: d.type, of: d.of, op: d.op } : { type: d.type }]));
|
|
9582
|
-
const email = nonEmpty(plan.business.facts?.email) ? plan.business.facts.email.trim() : null;
|
|
9660
|
+
const email = nonEmpty$1(plan.business.facts?.email) ? plan.business.facts.email.trim() : null;
|
|
9583
9661
|
const base = { plan, placeholders, issues, routes, year: context.year, email, navPage: null };
|
|
9584
9662
|
const pages = plan.pages.map(page => {
|
|
9585
9663
|
const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
|
|
@@ -9612,10 +9690,10 @@ function composeSite(plan, context) {
|
|
|
9612
9690
|
globals = planNavShape(globals, shape, {}, slot).globals;
|
|
9613
9691
|
}
|
|
9614
9692
|
return {
|
|
9615
|
-
source: clone(plan.source),
|
|
9616
|
-
business: clone(plan.business),
|
|
9617
|
-
design: resolveDesignChoices(clone(plan.design)),
|
|
9618
|
-
schemas: clone(plan.contentTypes),
|
|
9693
|
+
source: clone$1(plan.source),
|
|
9694
|
+
business: clone$1(plan.business),
|
|
9695
|
+
design: resolveDesignChoices(clone$1(plan.design)),
|
|
9696
|
+
schemas: clone$1(plan.contentTypes),
|
|
9619
9697
|
items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
|
|
9620
9698
|
dataSources: plan.dataSources.map(composeDataSource),
|
|
9621
9699
|
pages,
|
|
@@ -9653,14 +9731,14 @@ function navPagePlans(pages) {
|
|
|
9653
9731
|
* no /contact page: an email to the business, else removed), and the pattern's phone shape. Pure.
|
|
9654
9732
|
*/
|
|
9655
9733
|
function composeNavHeader(input, options) {
|
|
9656
|
-
if (!nonEmpty(options?.id))
|
|
9734
|
+
if (!nonEmpty$1(options?.id))
|
|
9657
9735
|
throw new ComposeError('composeNavHeader needs the header id');
|
|
9658
9736
|
const pattern = patternById(SITE_HEADER_PATTERN);
|
|
9659
9737
|
const all = navPagePlans(input.pages);
|
|
9660
9738
|
const navRoutes = new Set(siteNavPages(input.pages).map(p => p.route));
|
|
9661
9739
|
const pages = all.map(p => ({ ...p, inNav: navRoutes.has(p.route) }));
|
|
9662
9740
|
const plan = { business: input.business, pages, dataSources: [], contentTypes: [] };
|
|
9663
|
-
const email = nonEmpty(input.business.facts?.email) ? input.business.facts.email.trim() : null;
|
|
9741
|
+
const email = nonEmpty$1(input.business.facts?.email) ? input.business.facts.email.trim() : null;
|
|
9664
9742
|
const issues = [];
|
|
9665
9743
|
const ctx = {
|
|
9666
9744
|
owner: GlobalSlot.Header, page: null, section: null, plan, year: 0, email, navPage: null, nextAlias: null,
|
|
@@ -9684,7 +9762,7 @@ const PRICING_PATTERN = 'pricing.tiers';
|
|
|
9684
9762
|
* words, unlinked, for the author to link. Pure.
|
|
9685
9763
|
*/
|
|
9686
9764
|
function composeSection(patternId, input, options) {
|
|
9687
|
-
if (!nonEmpty(options?.id))
|
|
9765
|
+
if (!nonEmpty$1(options?.id))
|
|
9688
9766
|
throw new ComposeError('composeSection needs the section id');
|
|
9689
9767
|
const pattern = patternById(patternId);
|
|
9690
9768
|
if (!pattern)
|
|
@@ -9694,7 +9772,7 @@ function composeSection(patternId, input, options) {
|
|
|
9694
9772
|
const business = input.business ?? { name: '' };
|
|
9695
9773
|
const pages = navPagePlans(input.pages ?? []);
|
|
9696
9774
|
const plan = { business, pages, dataSources: [], contentTypes: [] };
|
|
9697
|
-
const email = nonEmpty(business.facts?.email) ? business.facts.email.trim() : null;
|
|
9775
|
+
const email = nonEmpty$1(business.facts?.email) ? business.facts.email.trim() : null;
|
|
9698
9776
|
const placeholders = [];
|
|
9699
9777
|
const issues = [];
|
|
9700
9778
|
const ctx = {
|
|
@@ -9731,13 +9809,13 @@ const ENTRANCE_TRIGGER = 'viewportEnter';
|
|
|
9731
9809
|
const ENTRANCE_EASING = 'ease-out';
|
|
9732
9810
|
const LEVELS = SPEC_AXES.animations;
|
|
9733
9811
|
const FAMILIES = SPEC_AXES.animationFamilies;
|
|
9734
|
-
const NOTHING = { content: null, parts: {} };
|
|
9812
|
+
const NOTHING$1 = { content: null, parts: {} };
|
|
9735
9813
|
/** What a section of `family` moves at `level` — nothing for an unknown level or family. */
|
|
9736
9814
|
function sectionEntrances(level, family) {
|
|
9737
9815
|
const lv = level ? LEVELS[level] : undefined;
|
|
9738
9816
|
const allow = FAMILIES[family] ?? [];
|
|
9739
9817
|
if (!lv || !allow.length)
|
|
9740
|
-
return NOTHING;
|
|
9818
|
+
return NOTHING$1;
|
|
9741
9819
|
if (lv.parts.content && allow.includes(EntrancePart.Content))
|
|
9742
9820
|
return { content: lv.parts.content, parts: {} };
|
|
9743
9821
|
const parts = {};
|
|
@@ -9858,6 +9936,8 @@ var BuildStep;
|
|
|
9858
9936
|
BuildStep["Preflight"] = "preflight";
|
|
9859
9937
|
BuildStep["ContentTypes"] = "content-types";
|
|
9860
9938
|
BuildStep["SampleImages"] = "sample-images";
|
|
9939
|
+
/** I2: the curated stock photos the plan's empty picture slots take, copied into the studio (never hot-linked). */
|
|
9940
|
+
BuildStep["StockPhotos"] = "stock-photos";
|
|
9861
9941
|
BuildStep["Items"] = "items";
|
|
9862
9942
|
BuildStep["DataSources"] = "data-sources";
|
|
9863
9943
|
BuildStep["Pages"] = "pages";
|
|
@@ -9879,6 +9959,8 @@ var CreatedKind;
|
|
|
9879
9959
|
/** Fields the build added to a content type it found (types are shared by every organization of a studio). */
|
|
9880
9960
|
CreatedKind["ContentTypeFields"] = "content-type-fields";
|
|
9881
9961
|
CreatedKind["SampleImage"] = "sample-image";
|
|
9962
|
+
/** A stock photo copied into the studio's storage (`media/stock/…`), with its assets row. */
|
|
9963
|
+
CreatedKind["StockPhoto"] = "stock-photo";
|
|
9882
9964
|
CreatedKind["Item"] = "item";
|
|
9883
9965
|
CreatedKind["DataSource"] = "data-source";
|
|
9884
9966
|
CreatedKind["Page"] = "page";
|
|
@@ -9900,6 +9982,8 @@ var BuildNoticeCode;
|
|
|
9900
9982
|
BuildNoticeCode["NoStorage"] = "no-storage";
|
|
9901
9983
|
/** A content type the build reuses is not public in this studio: the site will not show its items. */
|
|
9902
9984
|
BuildNoticeCode["ContentTypeNotPublic"] = "content-type-not-public";
|
|
9985
|
+
/** The stock library could not be read: the picture slots keep the empty box, and the run says so. */
|
|
9986
|
+
BuildNoticeCode["StockUnavailable"] = "stock-unavailable";
|
|
9903
9987
|
})(BuildNoticeCode || (BuildNoticeCode = {}));
|
|
9904
9988
|
var PreflightRefusalCode;
|
|
9905
9989
|
(function (PreflightRefusalCode) {
|
|
@@ -9931,6 +10015,7 @@ const buildKeys = {
|
|
|
9931
10015
|
contentTypeFields: (name) => `schema-fields:${name}`,
|
|
9932
10016
|
item: (contentType, n) => `item:${contentType}:${n}`,
|
|
9933
10017
|
sampleImage: (n) => `sample-image:${n}`,
|
|
10018
|
+
stockPhoto: (libraryKey) => `stock-photo:${libraryKey}`,
|
|
9934
10019
|
dataSource: (alias) => `data-source:${alias}`,
|
|
9935
10020
|
page: (key) => `page:${key}`,
|
|
9936
10021
|
global: (nodeId) => `global:${nodeId}`,
|
|
@@ -10009,6 +10094,14 @@ var AiStep;
|
|
|
10009
10094
|
AiStep["NameStructures"] = "scanNaming";
|
|
10010
10095
|
})(AiStep || (AiStep = {}));
|
|
10011
10096
|
const AI_STEPS = Object.values(AiStep);
|
|
10097
|
+
/** Each step's name on the wire (the control plane's `label`, the console) — the screens translate their own. */
|
|
10098
|
+
const AI_STEP_LABELS = {
|
|
10099
|
+
[AiStep.ReadMessage]: 'Read your message',
|
|
10100
|
+
[AiStep.PlanSite]: 'Plan the site',
|
|
10101
|
+
[AiStep.ChangePlan]: 'Change the plan',
|
|
10102
|
+
[AiStep.MakePictures]: 'Make pictures',
|
|
10103
|
+
[AiStep.NameStructures]: 'Name repeated structures',
|
|
10104
|
+
};
|
|
10012
10105
|
/** What a model must be able to do for a step to run on it. */
|
|
10013
10106
|
var AiCapability;
|
|
10014
10107
|
(function (AiCapability) {
|
|
@@ -10028,7 +10121,8 @@ const AI_STEP_TYPICAL_TOKENS = {
|
|
|
10028
10121
|
[AiStep.ReadMessage]: { input: 8_800, output: 1_500, cacheWrite: 0, cacheRead: 0 },
|
|
10029
10122
|
[AiStep.PlanSite]: { input: 12_000, output: 7_000, cacheWrite: 0, cacheRead: 0 },
|
|
10030
10123
|
[AiStep.ChangePlan]: { input: 6_000, output: 500, cacheWrite: 0, cacheRead: 0 },
|
|
10031
|
-
|
|
10124
|
+
// a 1 K picture is 1,120 image tokens (Google's figure for Nano Banana 2) — I-D4
|
|
10125
|
+
[AiStep.MakePictures]: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, imageOutput: 1_120 },
|
|
10032
10126
|
[AiStep.NameStructures]: { input: 80_000, output: 10_000, cacheWrite: 0, cacheRead: 0 },
|
|
10033
10127
|
};
|
|
10034
10128
|
/** 1 credit = 1 US cent of what the AI actually cost (decision D4). */
|
|
@@ -10042,7 +10136,8 @@ function costUsd(tokens, price) {
|
|
|
10042
10136
|
return tokens.input * perToken(price.input, 0)
|
|
10043
10137
|
+ tokens.output * perToken(price.output, 0)
|
|
10044
10138
|
+ tokens.cacheWrite * perToken(price.cacheWrite, price.input)
|
|
10045
|
-
+ tokens.cacheRead * perToken(price.cacheRead, price.input)
|
|
10139
|
+
+ tokens.cacheRead * perToken(price.cacheRead, price.input)
|
|
10140
|
+
+ (tokens.imageOutput ?? 0) * perToken(price.imageOutput, price.output);
|
|
10046
10141
|
}
|
|
10047
10142
|
/** Dollars → credits, kept to a millionth of a credit (a short read costs a fraction of one). */
|
|
10048
10143
|
function creditsOfUsd(usd) {
|
|
@@ -10122,6 +10217,1135 @@ var AiCallStatus;
|
|
|
10122
10217
|
AiCallStatus["Interrupted"] = "interrupted";
|
|
10123
10218
|
})(AiCallStatus || (AiCallStatus = {}));
|
|
10124
10219
|
|
|
10220
|
+
/**
|
|
10221
|
+
* The structured-output format a model writes a Site Plan in (AI plan §3d step 4, M1-3) — built from the SAME shape
|
|
10222
|
+
* table `validateSitePlan` reads (`SPEC_SITE_PLAN_SHAPE`) and the SAME pattern library, so the plan a model is told
|
|
10223
|
+
* about is the plan the gate refuses. ONE source: a key the table gains breaks this builder until it is written here.
|
|
10224
|
+
*
|
|
10225
|
+
* Two maps of the shape are written as LISTS on the wire, because no strict mode can say "any key": a section's
|
|
10226
|
+
* `slots` (`[{ slot, text }]`, the slot names closed per pattern) and a seed's field values (`values: [{ field,
|
|
10227
|
+
* value }]`). `sitePlanFromWire` turns them back; nothing else differs. Every section is one `anyOf` branch per
|
|
10228
|
+
* pattern — its id, its declared slots, its optional parts, a binding only when it takes one — and the design, the
|
|
10229
|
+
* source and the version are PINNED to the owner's pick and this run (one-value enums), so the provider cannot let
|
|
10230
|
+
* a model change the look. The gate still checks both (`plan.design` deep-equals the pick).
|
|
10231
|
+
*/
|
|
10232
|
+
const SITE_PLAN_SCHEMA_NAME = 'site_plan';
|
|
10233
|
+
/** The wire keys of the two maps written as lists. */
|
|
10234
|
+
var SitePlanWireKey;
|
|
10235
|
+
(function (SitePlanWireKey) {
|
|
10236
|
+
SitePlanWireKey["SlotName"] = "slot";
|
|
10237
|
+
SitePlanWireKey["SlotText"] = "text";
|
|
10238
|
+
SitePlanWireKey["SeedValues"] = "values";
|
|
10239
|
+
SitePlanWireKey["SeedField"] = "field";
|
|
10240
|
+
SitePlanWireKey["SeedValue"] = "value";
|
|
10241
|
+
})(SitePlanWireKey || (SitePlanWireKey = {}));
|
|
10242
|
+
const SHAPE = SPEC_SITE_PLAN_SHAPE;
|
|
10243
|
+
const one$1 = (value) => jsonOneOf([value]);
|
|
10244
|
+
const oneBoolean = (value) => ({ type: 'boolean', enum: [value] });
|
|
10245
|
+
/** The pick, key by key, as one-value enums; an optional key the pick leaves out may only be left out (or empty). */
|
|
10246
|
+
function pinnedDesign(design) {
|
|
10247
|
+
const entry = SHAPE['design'];
|
|
10248
|
+
const values = design;
|
|
10249
|
+
const properties = {};
|
|
10250
|
+
for (const key of Object.keys(entry).map(k => k.replace(/\?$/, ''))) {
|
|
10251
|
+
const v = values[key];
|
|
10252
|
+
if (typeof v === 'string')
|
|
10253
|
+
properties[key] = one$1(v);
|
|
10254
|
+
else if (typeof v === 'boolean')
|
|
10255
|
+
properties[key] = oneBoolean(v);
|
|
10256
|
+
else if (isPlainObject(v))
|
|
10257
|
+
properties[key] = jsonClosedObject(Object.fromEntries(Object.entries(v).map(([role, family]) => [role, one$1(String(family))])), Object.keys(v));
|
|
10258
|
+
else
|
|
10259
|
+
properties[key] = jsonClosedObject({}, []);
|
|
10260
|
+
}
|
|
10261
|
+
const shaped = jsonShapedObject(entry, properties, 'design');
|
|
10262
|
+
// Everything the pick carries is required: a model may not leave a choice out and let a default stand in.
|
|
10263
|
+
const given = Object.keys(values).filter(k => values[k] !== undefined && k in properties);
|
|
10264
|
+
return { ...shaped, required: [...new Set([...shaped['required'], ...given])] };
|
|
10265
|
+
}
|
|
10266
|
+
function sourceSchema(source) {
|
|
10267
|
+
if (source.kind === SitePlanSourceKind.Ai)
|
|
10268
|
+
return jsonShapedObject(SHAPE['sourceAi'], { kind: one$1(source.kind), runId: one$1(source.runId) }, 'sourceAi');
|
|
10269
|
+
return jsonShapedObject(SHAPE['sourceTemplate'], {
|
|
10270
|
+
kind: one$1(source.kind), templateId: one$1(source.templateId), templateVersion: { type: 'integer', enum: [source.templateVersion] },
|
|
10271
|
+
}, 'sourceTemplate');
|
|
10272
|
+
}
|
|
10273
|
+
function businessSchema() {
|
|
10274
|
+
const facts = jsonClosedObject(Object.fromEntries(FACT_KEYS.map(key => [key, jsonText()])), []);
|
|
10275
|
+
return jsonShapedObject(SHAPE['business'], { name: jsonText(), tagline: jsonText(), facts }, 'business');
|
|
10276
|
+
}
|
|
10277
|
+
/** A content type's field. */
|
|
10278
|
+
function fieldJsonSchema() {
|
|
10279
|
+
return jsonShapedObject(SHAPE['field'], { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() }, 'field');
|
|
10280
|
+
}
|
|
10281
|
+
/** A content type, its seed's field values as a list (`values: [{ field, value }]`). */
|
|
10282
|
+
function contentTypeJsonSchema() {
|
|
10283
|
+
const value = jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
|
|
10284
|
+
const values = jsonListOf(jsonClosedObject({ [SitePlanWireKey.SeedField]: jsonText(), [SitePlanWireKey.SeedValue]: value }, [SitePlanWireKey.SeedField, SitePlanWireKey.SeedValue]));
|
|
10285
|
+
const seed = jsonShapedObject(SHAPE['seed'], { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean() }, 'seed', { key: SitePlanWireKey.SeedValues, schema: values });
|
|
10286
|
+
return jsonShapedObject(SHAPE['contentType'], {
|
|
10287
|
+
name: jsonText(), label: jsonText(), previewTextField: jsonText(), previewImageField: jsonText(), fields: jsonListOf(fieldJsonSchema()), seed,
|
|
10288
|
+
}, 'contentType');
|
|
10289
|
+
}
|
|
10290
|
+
/** A data source: a cms list / single item, or a relative one. */
|
|
10291
|
+
function dataSourceJsonSchema() {
|
|
10292
|
+
const byRoute = jsonClosedObject({ field: jsonText(), routeParam: jsonText() }, ['field', 'routeParam']);
|
|
10293
|
+
const cms = jsonShapedObject(SHAPE['cmsDataSource'], {
|
|
10294
|
+
alias: jsonText(), type: one$1(PlanDataSourceType.Cms), mode: jsonOneOf(Object.values(PlanDataSourceMode)),
|
|
10295
|
+
contentType: jsonText(), limit: jsonInteger(), filterByRouteParam: byRoute,
|
|
10296
|
+
}, 'cmsDataSource');
|
|
10297
|
+
const relative = jsonShapedObject(SHAPE['relativeDataSource'], {
|
|
10298
|
+
alias: jsonText(), type: one$1(PlanDataSourceType.Relative), of: jsonText(), op: jsonOneOf(Object.values(RelativeOp)), within: jsonText(),
|
|
10299
|
+
}, 'relativeDataSource');
|
|
10300
|
+
return jsonAnyOf([cms, relative]);
|
|
10301
|
+
}
|
|
10302
|
+
/** The patterns a page section may use (every non-global one), in library order. */
|
|
10303
|
+
function sectionPatternIds() {
|
|
10304
|
+
return Object.entries(SITE_PATTERNS).filter(([, p]) => !p.global).map(([id]) => id);
|
|
10305
|
+
}
|
|
10306
|
+
/** A key a pattern has nothing for: only `{}` (or leaving it out) — the decoder drops it. */
|
|
10307
|
+
const NOTHING = () => jsonClosedObject({}, []);
|
|
10308
|
+
/** One section branch: this pattern's id, its declared slots (a list), its optional parts, a binding when it takes one. */
|
|
10309
|
+
function sectionBranch(id, pattern) {
|
|
10310
|
+
const slots = patternSlots(pattern);
|
|
10311
|
+
const parts = patternOptionalParts(pattern);
|
|
10312
|
+
const slotList = slots.length
|
|
10313
|
+
? jsonListOf(jsonClosedObject({ [SitePlanWireKey.SlotName]: jsonOneOf(slots), [SitePlanWireKey.SlotText]: jsonText() }, [SitePlanWireKey.SlotName, SitePlanWireKey.SlotText]))
|
|
10314
|
+
: NOTHING();
|
|
10315
|
+
const branch = jsonShapedObject(SHAPE['section'], {
|
|
10316
|
+
pattern: one$1(id),
|
|
10317
|
+
slots: slotList,
|
|
10318
|
+
binding: patternTakesBinding(pattern) ? jsonText() : NOTHING(),
|
|
10319
|
+
without: parts.length ? jsonListOf(jsonOneOf(parts)) : NOTHING(),
|
|
10320
|
+
}, 'section');
|
|
10321
|
+
return { ...branch, description: `${id}: ${pattern.label}` };
|
|
10322
|
+
}
|
|
10323
|
+
/** A page section: one branch per pattern. */
|
|
10324
|
+
function sectionJsonSchema() {
|
|
10325
|
+
return jsonAnyOf(sectionPatternIds().map(id => sectionBranch(id, SITE_PATTERNS[id])));
|
|
10326
|
+
}
|
|
10327
|
+
/** A page. */
|
|
10328
|
+
function pageJsonSchema() {
|
|
10329
|
+
return jsonShapedObject(SHAPE['page'], {
|
|
10330
|
+
key: jsonText(), name: jsonText(), route: jsonText(), isHome: jsonBoolean(), required: jsonBoolean(), inNav: jsonBoolean(),
|
|
10331
|
+
template: jsonText(), routeSource: jsonText(), sections: jsonListOf(sectionJsonSchema()),
|
|
10332
|
+
}, 'page');
|
|
10333
|
+
}
|
|
10334
|
+
const globalIds = (position) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
|
|
10335
|
+
/** The Site Plan format, pinned to the owner's pick and this run. */
|
|
10336
|
+
function sitePlanJsonSchema(pin) {
|
|
10337
|
+
const globals = jsonShapedObject(SHAPE['globals'], {
|
|
10338
|
+
[GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
|
|
10339
|
+
[GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
|
|
10340
|
+
}, 'globals');
|
|
10341
|
+
return jsonShapedObject(SHAPE['plan'], {
|
|
10342
|
+
version: one$1(SITE_PLAN_VERSION),
|
|
10343
|
+
source: sourceSchema(pin.source),
|
|
10344
|
+
business: businessSchema(),
|
|
10345
|
+
design: pinnedDesign(pin.design),
|
|
10346
|
+
contentTypes: jsonListOf(contentTypeJsonSchema()),
|
|
10347
|
+
dataSources: jsonListOf(dataSourceJsonSchema()),
|
|
10348
|
+
pages: jsonListOf(pageJsonSchema()),
|
|
10349
|
+
globals,
|
|
10350
|
+
}, 'plan');
|
|
10351
|
+
}
|
|
10352
|
+
// ── The wire → the plan ─────────────────────────────────────────────────────────────────────────────
|
|
10353
|
+
const isEmptyObject = (v) => isPlainObject(v) && Object.keys(v).length === 0;
|
|
10354
|
+
/** `[{ slot, text }]` → `{ slot: text }`; anything that is not that list is left for the gate to refuse. */
|
|
10355
|
+
function slotsFromWire(slots) {
|
|
10356
|
+
if (!Array.isArray(slots))
|
|
10357
|
+
return slots;
|
|
10358
|
+
const out = {};
|
|
10359
|
+
for (const entry of slots) {
|
|
10360
|
+
if (!isPlainObject(entry) || typeof entry[SitePlanWireKey.SlotName] !== 'string')
|
|
10361
|
+
return slots;
|
|
10362
|
+
out[entry[SitePlanWireKey.SlotName]] = entry[SitePlanWireKey.SlotText];
|
|
10363
|
+
}
|
|
10364
|
+
return out;
|
|
10365
|
+
}
|
|
10366
|
+
/** A section off the wire: its slots back to a map; a key the pattern has nothing for (`{}`) dropped. */
|
|
10367
|
+
function sectionFromWire(section) {
|
|
10368
|
+
if (!isPlainObject(section))
|
|
10369
|
+
return section;
|
|
10370
|
+
const out = { ...section };
|
|
10371
|
+
for (const key of ['slots', 'binding', 'without'])
|
|
10372
|
+
if (isEmptyObject(out[key]))
|
|
10373
|
+
delete out[key];
|
|
10374
|
+
if ('slots' in out)
|
|
10375
|
+
out['slots'] = slotsFromWire(out['slots']);
|
|
10376
|
+
return out;
|
|
10377
|
+
}
|
|
10378
|
+
/** A content type off the wire: its seed's `values` list back to field keys. */
|
|
10379
|
+
function contentTypeFromWire(type) {
|
|
10380
|
+
if (!isPlainObject(type) || !isPlainObject(type['seed']))
|
|
10381
|
+
return type;
|
|
10382
|
+
const { [SitePlanWireKey.SeedValues]: values, ...seed } = type['seed'];
|
|
10383
|
+
if (Array.isArray(values)) {
|
|
10384
|
+
for (const entry of values) {
|
|
10385
|
+
if (isPlainObject(entry) && typeof entry[SitePlanWireKey.SeedField] === 'string')
|
|
10386
|
+
seed[entry[SitePlanWireKey.SeedField]] = entry[SitePlanWireKey.SeedValue];
|
|
10387
|
+
}
|
|
10388
|
+
}
|
|
10389
|
+
else if (values !== undefined)
|
|
10390
|
+
seed[SitePlanWireKey.SeedValues] = values;
|
|
10391
|
+
return { ...type, seed };
|
|
10392
|
+
}
|
|
10393
|
+
function pageFromWire(page) {
|
|
10394
|
+
if (!isPlainObject(page) || !Array.isArray(page['sections']))
|
|
10395
|
+
return page;
|
|
10396
|
+
return { ...page, sections: page['sections'].map(sectionFromWire) };
|
|
10397
|
+
}
|
|
10398
|
+
/** The design off the wire: an empty optional object (a choice the pick does not carry) dropped. */
|
|
10399
|
+
function designFromWire(design) {
|
|
10400
|
+
if (!isPlainObject(design))
|
|
10401
|
+
return design;
|
|
10402
|
+
return Object.fromEntries(Object.entries(design).filter(([, v]) => !isEmptyObject(v)));
|
|
10403
|
+
}
|
|
10404
|
+
/**
|
|
10405
|
+
* A model's answer → a Site Plan: the two listed maps back to maps, nothing else touched. Never repairs — whatever the
|
|
10406
|
+
* answer got wrong is still wrong, for `validateSitePlan` to refuse with a path.
|
|
10407
|
+
*/
|
|
10408
|
+
function sitePlanFromWire(wire) {
|
|
10409
|
+
if (!isPlainObject(wire))
|
|
10410
|
+
return wire;
|
|
10411
|
+
const out = { ...wire };
|
|
10412
|
+
if (Array.isArray(out['contentTypes']))
|
|
10413
|
+
out['contentTypes'] = out['contentTypes'].map(contentTypeFromWire);
|
|
10414
|
+
if (Array.isArray(out['pages']))
|
|
10415
|
+
out['pages'] = out['pages'].map(pageFromWire);
|
|
10416
|
+
if ('design' in out)
|
|
10417
|
+
out['design'] = designFromWire(out['design']);
|
|
10418
|
+
return out;
|
|
10419
|
+
}
|
|
10420
|
+
/** The plan → the wire (what a re-ask shows the model, and what the plan edits' prompt carries). */
|
|
10421
|
+
function sitePlanToWire(plan) {
|
|
10422
|
+
const slotsToWire = (slots) => Object.entries(slots ?? {}).map(([slot, text]) => ({ [SitePlanWireKey.SlotName]: slot, [SitePlanWireKey.SlotText]: text }));
|
|
10423
|
+
return {
|
|
10424
|
+
...plan,
|
|
10425
|
+
contentTypes: plan.contentTypes.map(t => {
|
|
10426
|
+
if (!t.seed)
|
|
10427
|
+
return t;
|
|
10428
|
+
const { count, sample, isPublished, ...values } = t.seed;
|
|
10429
|
+
return { ...t, seed: { count, sample, isPublished, [SitePlanWireKey.SeedValues]: Object.entries(values).map(([field, value]) => ({ [SitePlanWireKey.SeedField]: field, [SitePlanWireKey.SeedValue]: value })) } };
|
|
10430
|
+
}),
|
|
10431
|
+
pages: plan.pages.map(p => ({ ...p, sections: p.sections.map(s => (s.slots ? { ...s, slots: slotsToWire(s.slots) } : s)) })),
|
|
10432
|
+
};
|
|
10433
|
+
}
|
|
10434
|
+
|
|
10435
|
+
/**
|
|
10436
|
+
* Changing a plan by sentence (AI plan §3d "The Plan prompt box", M1-5; reused by M2 after a build): the CLOSED list
|
|
10437
|
+
* of operations (N-D6) a model may write against the current plan, and the ONE pure `applyPlanEdits` that carries them
|
|
10438
|
+
* out. Nothing is written until Build, so an edit is a plan → plan function; the result goes through
|
|
10439
|
+
* `validateSitePlan` like any plan. Pages are named by `key`, sections by their index on the page. A set of edits is
|
|
10440
|
+
* applied whole or not at all: one edit that cannot apply refuses the set, with a path, and the plan is unchanged.
|
|
10441
|
+
*/
|
|
10442
|
+
var PlanEditOp;
|
|
10443
|
+
(function (PlanEditOp) {
|
|
10444
|
+
PlanEditOp["AddPage"] = "addPage";
|
|
10445
|
+
PlanEditOp["RemovePage"] = "removePage";
|
|
10446
|
+
PlanEditOp["RenamePage"] = "renamePage";
|
|
10447
|
+
PlanEditOp["MergePages"] = "mergePages";
|
|
10448
|
+
/** Its place in the menu (the plan's page order). */
|
|
10449
|
+
PlanEditOp["MovePage"] = "movePage";
|
|
10450
|
+
PlanEditOp["AddSection"] = "addSection";
|
|
10451
|
+
PlanEditOp["RemoveSection"] = "removeSection";
|
|
10452
|
+
PlanEditOp["MoveSection"] = "moveSection";
|
|
10453
|
+
PlanEditOp["SwapPattern"] = "swapPattern";
|
|
10454
|
+
PlanEditOp["SetSlot"] = "setSlot";
|
|
10455
|
+
PlanEditOp["AddContentType"] = "addContentType";
|
|
10456
|
+
PlanEditOp["AddField"] = "addField";
|
|
10457
|
+
PlanEditOp["SetSectionData"] = "setSectionData";
|
|
10458
|
+
})(PlanEditOp || (PlanEditOp = {}));
|
|
10459
|
+
/**
|
|
10460
|
+
* Every op's keys, explained — what the format is built from and what `validatePlanEdits` reads (`key?` optional).
|
|
10461
|
+
* A page is named by its `key`; a section by its 0-based `index` on that page.
|
|
10462
|
+
*/
|
|
10463
|
+
const PLAN_EDIT_SHAPE = {
|
|
10464
|
+
[PlanEditOp.AddPage]: { op: "'addPage'", page: 'page — a whole new page (a unique key and route)', 'after?': 'string — the key of the page it follows in the menu; absent: last' },
|
|
10465
|
+
[PlanEditOp.RemovePage]: { op: "'removePage'", page: 'string — the key of the page to remove (never the home page)' },
|
|
10466
|
+
[PlanEditOp.RenamePage]: { op: "'renamePage'", page: 'string — a page key', 'name?': 'string — its new name (the menu label)', 'route?': 'string — its new route' },
|
|
10467
|
+
[PlanEditOp.MergePages]: { op: "'mergePages'", into: 'string — the page that stays', from: 'string — the page whose sections move into it, then removed' },
|
|
10468
|
+
[PlanEditOp.MovePage]: { op: "'movePage'", page: 'string — a page key', to: 'integer — its new 0-based place in the menu' },
|
|
10469
|
+
[PlanEditOp.AddSection]: { op: "'addSection'", page: 'string — a page key', section: 'section', 'at?': 'integer — its 0-based place on the page; absent: last' },
|
|
10470
|
+
[PlanEditOp.RemoveSection]: { op: "'removeSection'", page: 'string — a page key', index: 'integer — the section' },
|
|
10471
|
+
[PlanEditOp.MoveSection]: { op: "'moveSection'", page: 'string — a page key', from: 'integer — the section', to: 'integer — its new place' },
|
|
10472
|
+
[PlanEditOp.SwapPattern]: { op: "'swapPattern'", page: 'string — a page key', index: 'integer — the section', pattern: 'string — the pattern it becomes (its slots kept where the new one has them)' },
|
|
10473
|
+
[PlanEditOp.SetSlot]: { op: "'setSlot'", page: 'string — a page key', index: 'integer — the section', slot: "string — one of the pattern's slots", text: "string — the words, in the owner's language and voice" },
|
|
10474
|
+
[PlanEditOp.AddContentType]: { op: "'addContentType'", contentType: 'contentType — a unique name', 'dataSources?': 'dataSource[] — the list / single sources its pages read' },
|
|
10475
|
+
[PlanEditOp.AddField]: { op: "'addField'", contentType: 'string — a content type of the plan', field: 'field', 'sample?': "string | string[] — the samples' value for it" },
|
|
10476
|
+
[PlanEditOp.SetSectionData]: { op: "'setSectionData'", page: 'string — a page key', index: 'integer — the section', binding: 'string — the list source the section repeats' },
|
|
10477
|
+
};
|
|
10478
|
+
var PlanChangeKind;
|
|
10479
|
+
(function (PlanChangeKind) {
|
|
10480
|
+
PlanChangeKind["Page"] = "page";
|
|
10481
|
+
PlanChangeKind["Section"] = "section";
|
|
10482
|
+
PlanChangeKind["ContentType"] = "contentType";
|
|
10483
|
+
PlanChangeKind["Field"] = "field";
|
|
10484
|
+
PlanChangeKind["DataSource"] = "dataSource";
|
|
10485
|
+
})(PlanChangeKind || (PlanChangeKind = {}));
|
|
10486
|
+
var PlanChangeAction;
|
|
10487
|
+
(function (PlanChangeAction) {
|
|
10488
|
+
PlanChangeAction["Added"] = "added";
|
|
10489
|
+
PlanChangeAction["Removed"] = "removed";
|
|
10490
|
+
PlanChangeAction["Changed"] = "changed";
|
|
10491
|
+
PlanChangeAction["Moved"] = "moved";
|
|
10492
|
+
})(PlanChangeAction || (PlanChangeAction = {}));
|
|
10493
|
+
var PlanEditIssueCode;
|
|
10494
|
+
(function (PlanEditIssueCode) {
|
|
10495
|
+
PlanEditIssueCode["Shape"] = "shape";
|
|
10496
|
+
PlanEditIssueCode["UnknownKey"] = "unknown-key";
|
|
10497
|
+
PlanEditIssueCode["Duplicate"] = "duplicate";
|
|
10498
|
+
PlanEditIssueCode["UnknownOp"] = "unknown-op";
|
|
10499
|
+
PlanEditIssueCode["UnknownPage"] = "unknown-page";
|
|
10500
|
+
PlanEditIssueCode["UnknownSection"] = "unknown-section";
|
|
10501
|
+
PlanEditIssueCode["UnknownPattern"] = "unknown-pattern";
|
|
10502
|
+
PlanEditIssueCode["UnknownSlot"] = "unknown-slot";
|
|
10503
|
+
PlanEditIssueCode["UnknownContentType"] = "unknown-content-type";
|
|
10504
|
+
PlanEditIssueCode["UnknownDataSource"] = "unknown-data-source";
|
|
10505
|
+
/** The home page stays (a plan has exactly one). */
|
|
10506
|
+
PlanEditIssueCode["HomePage"] = "home-page";
|
|
10507
|
+
/** A template page (one item per route) and a plain page cannot be merged. */
|
|
10508
|
+
PlanEditIssueCode["MergeTemplate"] = "merge-template";
|
|
10509
|
+
})(PlanEditIssueCode || (PlanEditIssueCode = {}));
|
|
10510
|
+
class Collector extends PathIssueCollector {
|
|
10511
|
+
constructor() {
|
|
10512
|
+
super('plan edit', { shape: PlanEditIssueCode.Shape, unknownKey: PlanEditIssueCode.UnknownKey, duplicate: PlanEditIssueCode.Duplicate });
|
|
10513
|
+
}
|
|
10514
|
+
}
|
|
10515
|
+
const OPS = Object.values(PlanEditOp);
|
|
10516
|
+
/** A page's own header section (`page-header.simple`) — what a merge leaves behind with the page it named. */
|
|
10517
|
+
const PAGE_HEADER_FAMILY = 'header';
|
|
10518
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
10519
|
+
const clamp = (n, max) => Math.max(0, Math.min(Math.trunc(n), max));
|
|
10520
|
+
/** The edits' structure: a known op, its keys (`PLAN_EDIT_SHAPE`), integers where an index is. Never repairs. */
|
|
10521
|
+
function validatePlanEdits(edits) {
|
|
10522
|
+
const c = new Collector();
|
|
10523
|
+
if (!Array.isArray(edits)) {
|
|
10524
|
+
c.add(PlanEditIssueCode.Shape, 'edits', 'must be an array');
|
|
10525
|
+
return { valid: false, issues: c.issues };
|
|
10526
|
+
}
|
|
10527
|
+
edits.forEach((raw, i) => {
|
|
10528
|
+
const path = `edits[${i}]`;
|
|
10529
|
+
if (!isPlainObject(raw))
|
|
10530
|
+
return c.add(PlanEditIssueCode.Shape, path, 'must be an object');
|
|
10531
|
+
const op = raw['op'];
|
|
10532
|
+
if (typeof op !== 'string' || !OPS.includes(op))
|
|
10533
|
+
return c.add(PlanEditIssueCode.UnknownOp, `${path}.op`, `must be one of ${OPS.join(', ')}`);
|
|
10534
|
+
const entry = PLAN_EDIT_SHAPE[op];
|
|
10535
|
+
c.keys(raw, shapeKeys(entry), path);
|
|
10536
|
+
for (const [key, text] of Object.entries(entry)) {
|
|
10537
|
+
const optional = key.endsWith('?');
|
|
10538
|
+
const name = optional ? key.slice(0, -1) : key;
|
|
10539
|
+
const v = raw[name];
|
|
10540
|
+
if (v === undefined) {
|
|
10541
|
+
if (!optional)
|
|
10542
|
+
c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'is required');
|
|
10543
|
+
continue;
|
|
10544
|
+
}
|
|
10545
|
+
if (text.startsWith('string | string[]')) {
|
|
10546
|
+
if (typeof v !== 'string' && !(Array.isArray(v) && v.every(x => typeof x === 'string')))
|
|
10547
|
+
c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be a string or a list of strings');
|
|
10548
|
+
}
|
|
10549
|
+
else if (text.startsWith('string') && !isNonEmptyString(v))
|
|
10550
|
+
c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be a non-empty string');
|
|
10551
|
+
if (text.startsWith('integer') && !Number.isInteger(v))
|
|
10552
|
+
c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be a whole number');
|
|
10553
|
+
if (['page', 'section', 'contentType', 'field'].includes(text.split(' ')[0]) && !isPlainObject(v))
|
|
10554
|
+
c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be an object');
|
|
10555
|
+
if (text.startsWith('dataSource[]') && !Array.isArray(v))
|
|
10556
|
+
c.add(PlanEditIssueCode.Shape, `${path}.${name}`, 'must be an array');
|
|
10557
|
+
}
|
|
10558
|
+
});
|
|
10559
|
+
return { valid: c.issues.length === 0, issues: c.issues };
|
|
10560
|
+
}
|
|
10561
|
+
/** The aliases a page reads (its route source and its sections' bindings). */
|
|
10562
|
+
function aliasesOf(pages) {
|
|
10563
|
+
const out = new Set();
|
|
10564
|
+
for (const page of pages) {
|
|
10565
|
+
if (page.routeSource)
|
|
10566
|
+
out.add(page.routeSource);
|
|
10567
|
+
for (const s of page.sections)
|
|
10568
|
+
if (s.binding)
|
|
10569
|
+
out.add(s.binding);
|
|
10570
|
+
}
|
|
10571
|
+
return out;
|
|
10572
|
+
}
|
|
10573
|
+
/** A relative source (`project.next`'s) is read while its subject is, and keeps the list it walks. */
|
|
10574
|
+
function withDependencies(aliases, sources) {
|
|
10575
|
+
const out = new Set(aliases);
|
|
10576
|
+
for (const s of sources) {
|
|
10577
|
+
if (s.type === PlanDataSourceType.Relative && aliases.has(s.of)) {
|
|
10578
|
+
out.add(s.alias);
|
|
10579
|
+
out.add(s.within);
|
|
10580
|
+
}
|
|
10581
|
+
}
|
|
10582
|
+
return out;
|
|
10583
|
+
}
|
|
10584
|
+
const pageIndex = (d, key) => d.pages.findIndex(p => p.key === key);
|
|
10585
|
+
function findPage(d, key, c, path) {
|
|
10586
|
+
const at = pageIndex(d, key);
|
|
10587
|
+
if (at < 0)
|
|
10588
|
+
c.add(PlanEditIssueCode.UnknownPage, path, `no page "${String(key)}" in the plan (${d.pages.map(p => p.key).join(', ')})`);
|
|
10589
|
+
return at;
|
|
10590
|
+
}
|
|
10591
|
+
function findSection(d, pageAt, index, c, path) {
|
|
10592
|
+
const ok = Number.isInteger(index) && index >= 0 && index < d.pages[pageAt].sections.length;
|
|
10593
|
+
if (!ok)
|
|
10594
|
+
c.add(PlanEditIssueCode.UnknownSection, path, `page "${d.pages[pageAt].key}" has sections 0–${d.pages[pageAt].sections.length - 1}`);
|
|
10595
|
+
return ok;
|
|
10596
|
+
}
|
|
10597
|
+
const setSections = (d, at, sections) => { d.pages[at] = { ...d.pages[at], sections }; };
|
|
10598
|
+
/** One handler per op — a data table, never an if-chain. */
|
|
10599
|
+
const APPLY = {
|
|
10600
|
+
[PlanEditOp.AddPage]: (d, e, c, path, changed) => {
|
|
10601
|
+
const page = { ...e.page, sections: (e.page.sections ?? []).map(sectionFromWire) };
|
|
10602
|
+
if (pageIndex(d, page.key) >= 0)
|
|
10603
|
+
return c.add(PlanEditIssueCode.Duplicate, `${path}.page.key`, `page "${page.key}" is already in the plan`);
|
|
10604
|
+
const after = e.after === undefined ? d.pages.length - 1 : findPage(d, e.after, c, `${path}.after`);
|
|
10605
|
+
if (after < 0 && e.after !== undefined)
|
|
10606
|
+
return;
|
|
10607
|
+
d.pages.splice(after + 1, 0, page);
|
|
10608
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Added, page: page.key, name: page.name });
|
|
10609
|
+
},
|
|
10610
|
+
[PlanEditOp.RemovePage]: (d, e, c, path, changed) => {
|
|
10611
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10612
|
+
if (at < 0)
|
|
10613
|
+
return;
|
|
10614
|
+
if (d.pages[at].isHome)
|
|
10615
|
+
return c.add(PlanEditIssueCode.HomePage, `${path}.page`, 'the home page stays — rename or change it instead');
|
|
10616
|
+
const [gone] = d.pages.splice(at, 1);
|
|
10617
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Removed, page: gone.key, name: gone.name });
|
|
10618
|
+
},
|
|
10619
|
+
[PlanEditOp.RenamePage]: (d, e, c, path, changed) => {
|
|
10620
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10621
|
+
if (at < 0)
|
|
10622
|
+
return;
|
|
10623
|
+
d.pages[at] = { ...d.pages[at], ...(e.name !== undefined ? { name: e.name } : {}), ...(e.route !== undefined ? { route: e.route } : {}) };
|
|
10624
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Changed, page: d.pages[at].key, name: d.pages[at].name });
|
|
10625
|
+
},
|
|
10626
|
+
[PlanEditOp.MergePages]: (d, e, c, path, changed) => {
|
|
10627
|
+
const into = findPage(d, e.into, c, `${path}.into`);
|
|
10628
|
+
const from = findPage(d, e.from, c, `${path}.from`);
|
|
10629
|
+
if (into < 0 || from < 0)
|
|
10630
|
+
return;
|
|
10631
|
+
if (into === from)
|
|
10632
|
+
return c.add(PlanEditIssueCode.Duplicate, `${path}.from`, 'a page is merged into another page');
|
|
10633
|
+
const a = d.pages[into];
|
|
10634
|
+
const b = d.pages[from];
|
|
10635
|
+
if (!!a.template !== !!b.template)
|
|
10636
|
+
return c.add(PlanEditIssueCode.MergeTemplate, `${path}.from`, 'a page per item and a plain page cannot be merged');
|
|
10637
|
+
if (b.isHome)
|
|
10638
|
+
return c.add(PlanEditIssueCode.HomePage, `${path}.from`, 'the home page stays — merge the other page into it');
|
|
10639
|
+
const moved = b.sections.filter((s, i) => !(i === 0 && patternById(s.pattern)?.family === PAGE_HEADER_FAMILY));
|
|
10640
|
+
setSections(d, into, [...a.sections, ...moved]);
|
|
10641
|
+
moved.forEach((_, k) => changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: a.key, index: a.sections.length + k }));
|
|
10642
|
+
d.pages.splice(from, 1);
|
|
10643
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Changed, page: a.key, name: a.name });
|
|
10644
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Removed, page: b.key, name: b.name });
|
|
10645
|
+
},
|
|
10646
|
+
[PlanEditOp.MovePage]: (d, e, c, path, changed) => {
|
|
10647
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10648
|
+
if (at < 0)
|
|
10649
|
+
return;
|
|
10650
|
+
const [page] = d.pages.splice(at, 1);
|
|
10651
|
+
d.pages.splice(clamp(e.to, d.pages.length), 0, page);
|
|
10652
|
+
changed.push({ kind: PlanChangeKind.Page, action: PlanChangeAction.Moved, page: page.key, name: page.name });
|
|
10653
|
+
},
|
|
10654
|
+
[PlanEditOp.AddSection]: (d, e, c, path, changed) => {
|
|
10655
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10656
|
+
if (at < 0)
|
|
10657
|
+
return;
|
|
10658
|
+
const sections = [...d.pages[at].sections];
|
|
10659
|
+
const index = e.at === undefined ? sections.length : clamp(e.at, sections.length);
|
|
10660
|
+
sections.splice(index, 0, sectionFromWire(e.section));
|
|
10661
|
+
setSections(d, at, sections);
|
|
10662
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Added, page: d.pages[at].key, index });
|
|
10663
|
+
},
|
|
10664
|
+
[PlanEditOp.RemoveSection]: (d, e, c, path, changed) => {
|
|
10665
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10666
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`))
|
|
10667
|
+
return;
|
|
10668
|
+
setSections(d, at, d.pages[at].sections.filter((_, i) => i !== e.index));
|
|
10669
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Removed, page: d.pages[at].key, index: e.index });
|
|
10670
|
+
},
|
|
10671
|
+
[PlanEditOp.MoveSection]: (d, e, c, path, changed) => {
|
|
10672
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10673
|
+
if (at < 0 || !findSection(d, at, e.from, c, `${path}.from`))
|
|
10674
|
+
return;
|
|
10675
|
+
const sections = [...d.pages[at].sections];
|
|
10676
|
+
const [section] = sections.splice(e.from, 1);
|
|
10677
|
+
const to = clamp(e.to, sections.length);
|
|
10678
|
+
sections.splice(to, 0, section);
|
|
10679
|
+
setSections(d, at, sections);
|
|
10680
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Moved, page: d.pages[at].key, index: to });
|
|
10681
|
+
},
|
|
10682
|
+
[PlanEditOp.SwapPattern]: (d, e, c, path, changed) => {
|
|
10683
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10684
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`))
|
|
10685
|
+
return;
|
|
10686
|
+
const next = patternById(e.pattern);
|
|
10687
|
+
if (!next || next.global)
|
|
10688
|
+
return c.add(PlanEditIssueCode.UnknownPattern, `${path}.pattern`, `no section pattern "${String(e.pattern)}"`);
|
|
10689
|
+
const old = d.pages[at].sections[e.index];
|
|
10690
|
+
const slots = Object.fromEntries(Object.entries(old.slots ?? {}).filter(([slot]) => patternSlots(next).includes(slot)));
|
|
10691
|
+
const without = (old.without ?? []).filter(w => patternOptionalParts(next).includes(w));
|
|
10692
|
+
const section = {
|
|
10693
|
+
pattern: e.pattern,
|
|
10694
|
+
...(Object.keys(slots).length ? { slots } : {}),
|
|
10695
|
+
...(old.binding && patternTakesBinding(next) ? { binding: old.binding } : {}),
|
|
10696
|
+
...(without.length ? { without } : {}),
|
|
10697
|
+
};
|
|
10698
|
+
setSections(d, at, d.pages[at].sections.map((s, i) => (i === e.index ? section : s)));
|
|
10699
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
10700
|
+
},
|
|
10701
|
+
[PlanEditOp.SetSlot]: (d, e, c, path, changed) => {
|
|
10702
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10703
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`))
|
|
10704
|
+
return;
|
|
10705
|
+
const section = d.pages[at].sections[e.index];
|
|
10706
|
+
const declared = patternSlots(patternById(section.pattern));
|
|
10707
|
+
if (!declared.includes(e.slot))
|
|
10708
|
+
return c.add(PlanEditIssueCode.UnknownSlot, `${path}.slot`, `"${section.pattern}" has no slot "${e.slot}" (${declared.join(', ') || 'none'})`);
|
|
10709
|
+
setSections(d, at, d.pages[at].sections.map((s, i) => (i === e.index ? { ...s, slots: { ...(s.slots ?? {}), [e.slot]: e.text } } : s)));
|
|
10710
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
10711
|
+
},
|
|
10712
|
+
[PlanEditOp.AddContentType]: (d, e, c, path, changed) => {
|
|
10713
|
+
const type = contentTypeFromWire(e.contentType);
|
|
10714
|
+
if (d.contentTypes.some(t => t.name === type.name))
|
|
10715
|
+
return c.add(PlanEditIssueCode.Duplicate, `${path}.contentType.name`, `content type "${type.name}" is already in the plan`);
|
|
10716
|
+
d.contentTypes.push(type);
|
|
10717
|
+
changed.push({ kind: PlanChangeKind.ContentType, action: PlanChangeAction.Added, name: type.name });
|
|
10718
|
+
for (const [k, source] of (e.dataSources ?? []).entries()) {
|
|
10719
|
+
if (d.dataSources.some(s => s.alias === source.alias)) {
|
|
10720
|
+
c.add(PlanEditIssueCode.Duplicate, `${path}.dataSources[${k}].alias`, `data source "${source.alias}" is already in the plan`);
|
|
10721
|
+
continue;
|
|
10722
|
+
}
|
|
10723
|
+
d.dataSources.push(source);
|
|
10724
|
+
changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Added, name: source.alias });
|
|
10725
|
+
}
|
|
10726
|
+
},
|
|
10727
|
+
[PlanEditOp.AddField]: (d, e, c, path, changed) => {
|
|
10728
|
+
const at = d.contentTypes.findIndex(t => t.name === e.contentType);
|
|
10729
|
+
if (at < 0)
|
|
10730
|
+
return c.add(PlanEditIssueCode.UnknownContentType, `${path}.contentType`, `no content type "${String(e.contentType)}" in the plan`);
|
|
10731
|
+
const type = d.contentTypes[at];
|
|
10732
|
+
if (type.fields.some(f => f.name === e.field.name))
|
|
10733
|
+
return c.add(PlanEditIssueCode.Duplicate, `${path}.field.name`, `"${type.name}" already has a field "${e.field.name}"`);
|
|
10734
|
+
const seed = type.seed ? { ...type.seed, [e.field.name]: e.sample ?? '' } : undefined;
|
|
10735
|
+
d.contentTypes[at] = { ...type, fields: [...type.fields, e.field], ...(seed ? { seed } : {}) };
|
|
10736
|
+
changed.push({ kind: PlanChangeKind.Field, action: PlanChangeAction.Added, name: `${type.name}.${e.field.name}` });
|
|
10737
|
+
},
|
|
10738
|
+
[PlanEditOp.SetSectionData]: (d, e, c, path, changed) => {
|
|
10739
|
+
const at = findPage(d, e.page, c, `${path}.page`);
|
|
10740
|
+
if (at < 0 || !findSection(d, at, e.index, c, `${path}.index`))
|
|
10741
|
+
return;
|
|
10742
|
+
const source = d.dataSources.find(s => s.alias === e.binding);
|
|
10743
|
+
if (!source || source.type !== PlanDataSourceType.Cms || source.mode !== PlanDataSourceMode.Query) {
|
|
10744
|
+
return c.add(PlanEditIssueCode.UnknownDataSource, `${path}.binding`, `no list source "${String(e.binding)}" in the plan`);
|
|
10745
|
+
}
|
|
10746
|
+
setSections(d, at, d.pages[at].sections.map((s, i) => (i === e.index ? { ...s, binding: e.binding } : s)));
|
|
10747
|
+
changed.push({ kind: PlanChangeKind.Section, action: PlanChangeAction.Changed, page: d.pages[at].key, index: e.index });
|
|
10748
|
+
},
|
|
10749
|
+
};
|
|
10750
|
+
/**
|
|
10751
|
+
* Apply a set of edits to a plan — all of them, or none (any issue refuses the set and returns the plan unchanged). The
|
|
10752
|
+
* sources only removed pages and sections read are dropped with them; sources nothing read before are kept.
|
|
10753
|
+
* The result still has to pass `validateSitePlan`.
|
|
10754
|
+
*/
|
|
10755
|
+
function applyPlanEdits(plan, edits) {
|
|
10756
|
+
const structure = validatePlanEdits(edits);
|
|
10757
|
+
if (!structure.valid)
|
|
10758
|
+
return { plan, changed: [], issues: structure.issues };
|
|
10759
|
+
const draft = { pages: clone([...plan.pages]), contentTypes: clone([...plan.contentTypes]), dataSources: clone([...plan.dataSources]) };
|
|
10760
|
+
const c = new Collector();
|
|
10761
|
+
const changed = [];
|
|
10762
|
+
edits.forEach((edit, i) => APPLY[edit.op](draft, edit, c, `edits[${i}]`, changed));
|
|
10763
|
+
if (c.issues.length)
|
|
10764
|
+
return { plan, changed: [], issues: c.issues };
|
|
10765
|
+
const before = withDependencies(aliasesOf(plan.pages), plan.dataSources);
|
|
10766
|
+
const after = withDependencies(aliasesOf(draft.pages), draft.dataSources);
|
|
10767
|
+
const added = new Set(changed.filter(ch => ch.kind === PlanChangeKind.DataSource).map(ch => ch.name));
|
|
10768
|
+
const dataSources = draft.dataSources.filter(s => after.has(s.alias) || !before.has(s.alias) || added.has(s.alias));
|
|
10769
|
+
for (const s of draft.dataSources)
|
|
10770
|
+
if (!dataSources.includes(s))
|
|
10771
|
+
changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Removed, name: s.alias });
|
|
10772
|
+
return { plan: { ...plan, pages: draft.pages, contentTypes: draft.contentTypes, dataSources }, changed, issues: [] };
|
|
10773
|
+
}
|
|
10774
|
+
// ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
|
|
10775
|
+
const PLAN_EDITS_SCHEMA_NAME = 'plan_edits';
|
|
10776
|
+
/** A sentence's answer: the edits, or why the ops cannot say it (one line, in the owner's language). */
|
|
10777
|
+
var PlanEditsOutcome;
|
|
10778
|
+
(function (PlanEditsOutcome) {
|
|
10779
|
+
PlanEditsOutcome["Edits"] = "edits";
|
|
10780
|
+
PlanEditsOutcome["Cannot"] = "cannot";
|
|
10781
|
+
})(PlanEditsOutcome || (PlanEditsOutcome = {}));
|
|
10782
|
+
/** The answer's own keys, explained. */
|
|
10783
|
+
const PLAN_EDITS_ANSWER_SHAPE = {
|
|
10784
|
+
outcome: "'edits' | 'cannot' — cannot: the operations cannot do what the sentence asks",
|
|
10785
|
+
edits: 'edit[] — applied in order, all or none; empty when cannot',
|
|
10786
|
+
reason: "string — when cannot, ONE short line for the owner in their language (what to do instead); else ''",
|
|
10787
|
+
};
|
|
10788
|
+
const one = (value) => jsonOneOf([value]);
|
|
10789
|
+
/** The schema of each op's value types (what `PLAN_EDIT_SHAPE` names). */
|
|
10790
|
+
function opSchema(op) {
|
|
10791
|
+
const entry = PLAN_EDIT_SHAPE[op];
|
|
10792
|
+
const valueOf = (key) => {
|
|
10793
|
+
const text = entry[key] ?? entry[`${key}?`];
|
|
10794
|
+
if (key === 'op')
|
|
10795
|
+
return one(op);
|
|
10796
|
+
if (key === 'pattern')
|
|
10797
|
+
return jsonOneOf(sectionPatternIds());
|
|
10798
|
+
const head = text.split(' ')[0];
|
|
10799
|
+
const table = {
|
|
10800
|
+
page: pageJsonSchema, section: sectionJsonSchema, contentType: contentTypeJsonSchema, field: fieldJsonSchema,
|
|
10801
|
+
'dataSource[]': () => jsonListOf(dataSourceJsonSchema()), integer: jsonInteger, string: jsonText,
|
|
10802
|
+
};
|
|
10803
|
+
if (text.startsWith('string | string[]'))
|
|
10804
|
+
return jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
|
|
10805
|
+
return (table[head] ?? jsonText)();
|
|
10806
|
+
};
|
|
10807
|
+
return jsonShapedObject(entry, Object.fromEntries(shapeKeys(entry).map(key => [key, valueOf(key)])), op);
|
|
10808
|
+
}
|
|
10809
|
+
/** The format a sentence's answer is written in: the outcome, the edits (one `anyOf` branch per op), the reason. */
|
|
10810
|
+
function planEditsJsonSchema() {
|
|
10811
|
+
return jsonShapedObject(PLAN_EDITS_ANSWER_SHAPE, {
|
|
10812
|
+
outcome: jsonOneOf(Object.values(PlanEditsOutcome)),
|
|
10813
|
+
edits: jsonListOf(jsonAnyOf(OPS.map(op => opSchema(op)))),
|
|
10814
|
+
reason: jsonText(),
|
|
10815
|
+
}, 'planEdits');
|
|
10816
|
+
}
|
|
10817
|
+
/** A model's answer → edits (sections and content types off the wire); never repaired. */
|
|
10818
|
+
function planEditsFromWire(answer) {
|
|
10819
|
+
if (!isPlainObject(answer) || !Array.isArray(answer['edits']))
|
|
10820
|
+
return answer;
|
|
10821
|
+
const edits = answer['edits'].map(raw => {
|
|
10822
|
+
if (!isPlainObject(raw))
|
|
10823
|
+
return raw;
|
|
10824
|
+
const e = { ...raw };
|
|
10825
|
+
if (isPlainObject(e['page']) && Array.isArray(e['page']['sections'])) {
|
|
10826
|
+
e['page'] = { ...e['page'], sections: e['page']['sections'].map(sectionFromWire) };
|
|
10827
|
+
}
|
|
10828
|
+
if (isPlainObject(e['section']))
|
|
10829
|
+
e['section'] = sectionFromWire(e['section']);
|
|
10830
|
+
if (isPlainObject(e['contentType']))
|
|
10831
|
+
e['contentType'] = contentTypeFromWire(e['contentType']);
|
|
10832
|
+
return e;
|
|
10833
|
+
});
|
|
10834
|
+
return { ...answer, edits };
|
|
10835
|
+
}
|
|
10836
|
+
|
|
10837
|
+
/**
|
|
10838
|
+
* The plan stage's word checks (AI plan §3d / §3h, M1-3): every text a model WROTE into a plan — the tagline, page
|
|
10839
|
+
* names, section slots, content-type labels, sample values — scanned for the words the owner and their kind of
|
|
10840
|
+
* business must not use (`avoidWords`, the analysis's + the type's `words`), and for a fact the owner never gave
|
|
10841
|
+
* (an email, phone number, price or opening time that is not one of the owner's own facts). A finding is a refusal
|
|
10842
|
+
* with a path — the model rewrites; nothing here repairs a word. Pure.
|
|
10843
|
+
*/
|
|
10844
|
+
var PlanWordIssueCode;
|
|
10845
|
+
(function (PlanWordIssueCode) {
|
|
10846
|
+
/** A word the owner (or their kind of business) must not use. */
|
|
10847
|
+
PlanWordIssueCode["AvoidWord"] = "avoid-word";
|
|
10848
|
+
/** Something that looks like a fact — an email, phone, price or time — that is not one of the owner's facts. */
|
|
10849
|
+
PlanWordIssueCode["InventedFact"] = "invented-fact";
|
|
10850
|
+
/** `business.facts` holds a fact the owner did not give, or not in the owner's words. */
|
|
10851
|
+
PlanWordIssueCode["FactNotTheOwners"] = "fact-not-the-owners";
|
|
10852
|
+
})(PlanWordIssueCode || (PlanWordIssueCode = {}));
|
|
10853
|
+
/** Every text in a plan a model writes (never ids, routes, field names or pattern ids). */
|
|
10854
|
+
function writtenTexts(plan) {
|
|
10855
|
+
const out = [];
|
|
10856
|
+
const add = (path, text) => { if (typeof text === 'string' && text.trim())
|
|
10857
|
+
out.push({ path, text }); };
|
|
10858
|
+
add('business.tagline', plan.business?.tagline);
|
|
10859
|
+
plan.pages?.forEach((page, p) => {
|
|
10860
|
+
add(`pages[${p}].name`, page.name);
|
|
10861
|
+
page.sections?.forEach((section, s) => {
|
|
10862
|
+
for (const [slot, text] of Object.entries(section.slots ?? {}))
|
|
10863
|
+
add(`pages[${p}].sections[${s}].slots.${slot}`, text);
|
|
10864
|
+
});
|
|
10865
|
+
});
|
|
10866
|
+
plan.contentTypes?.forEach((type, t) => {
|
|
10867
|
+
add(`contentTypes[${t}].label`, type.label);
|
|
10868
|
+
for (const [field, value] of Object.entries(type.seed ?? {})) {
|
|
10869
|
+
const values = Array.isArray(value) ? value : [value];
|
|
10870
|
+
values.forEach((v, k) => add(`contentTypes[${t}].seed.${field}${Array.isArray(value) ? `[${k}]` : ''}`, v));
|
|
10871
|
+
}
|
|
10872
|
+
});
|
|
10873
|
+
return out;
|
|
10874
|
+
}
|
|
10875
|
+
const HEBREW = /[-]/;
|
|
10876
|
+
/** Hebrew joins up to two one-letter words (and, the, in, to, from, that, as) to the next: "והטוב" holds "טוב". */
|
|
10877
|
+
const HEBREW_PREFIXES = '[והבלמשכ]{0,2}';
|
|
10878
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
10879
|
+
const normalise = (s) => s.normalize('NFKC').toLowerCase();
|
|
10880
|
+
/** A word (or phrase) as a whole-word pattern; Hebrew allows its one-letter prefixes. */
|
|
10881
|
+
function wordPattern(word) {
|
|
10882
|
+
const w = escape(normalise(word.trim())).replace(/\s+/g, '\\s+');
|
|
10883
|
+
const prefix = HEBREW.test(word) ? HEBREW_PREFIXES : '';
|
|
10884
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${prefix}${w}(?![\\p{L}\\p{N}])`, 'u');
|
|
10885
|
+
}
|
|
10886
|
+
/** What a fact looks like when a model writes one — each judged against the owner's facts. */
|
|
10887
|
+
const FACT_SHAPES = [
|
|
10888
|
+
{ kind: 'email', pattern: /[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu, key: m => m.toLowerCase() },
|
|
10889
|
+
{ kind: 'phone', pattern: /\+?\d[\d\s().-]{6,}\d/g, key: m => m.replace(/\D/g, '') },
|
|
10890
|
+
{ kind: 'price', pattern: /(?:[$€£₪]\s?\d[\d,.]*|\d[\d,.]*\s?(?:[$€£₪]|ש"ח|NIS|USD|EUR|GBP))/gu, key: m => m.replace(/[^\d]/g, '') },
|
|
10891
|
+
{ kind: 'time', pattern: /(?<!\d)\d{1,2}[:.]\d{2}(?!\d)/g, key: m => m.replace(/\D/g, '') },
|
|
10892
|
+
];
|
|
10893
|
+
const MIN_PHONE_DIGITS = 8;
|
|
10894
|
+
/** "2019 – 2024" is two years, not a phone number. */
|
|
10895
|
+
const YEAR_RANGE = /^\s*\d{4}\s*[-–]\s*\d{4}\s*$/;
|
|
10896
|
+
function ownedKeys(facts) {
|
|
10897
|
+
const keys = new Set();
|
|
10898
|
+
for (const value of Object.values(facts)) {
|
|
10899
|
+
if (typeof value !== 'string')
|
|
10900
|
+
continue;
|
|
10901
|
+
for (const shape of FACT_SHAPES)
|
|
10902
|
+
for (const m of value.match(shape.pattern) ?? [])
|
|
10903
|
+
keys.add(`${shape.kind}:${shape.key(m)}`);
|
|
10904
|
+
}
|
|
10905
|
+
return keys;
|
|
10906
|
+
}
|
|
10907
|
+
/** The words to avoid and the facts never given, in every written text of the plan. */
|
|
10908
|
+
function planWordIssues(plan, ctx) {
|
|
10909
|
+
const issues = [];
|
|
10910
|
+
const avoid = [...new Set(ctx.avoidWords.map(w => w.trim()).filter(Boolean))].map(w => ({ word: w, pattern: wordPattern(w) }));
|
|
10911
|
+
const owned = ownedKeys(ctx.facts);
|
|
10912
|
+
for (const { path, text } of writtenTexts(plan)) {
|
|
10913
|
+
const words = normalise(text);
|
|
10914
|
+
for (const a of avoid)
|
|
10915
|
+
if (a.pattern.test(words))
|
|
10916
|
+
issues.push({ code: PlanWordIssueCode.AvoidWord, path, message: `uses "${a.word}" — a word this site must not use` });
|
|
10917
|
+
for (const shape of FACT_SHAPES) {
|
|
10918
|
+
for (const m of text.match(shape.pattern) ?? []) {
|
|
10919
|
+
const key = shape.key(m);
|
|
10920
|
+
if (shape.kind === 'phone' && (key.length < MIN_PHONE_DIGITS || YEAR_RANGE.test(m)))
|
|
10921
|
+
continue;
|
|
10922
|
+
if (!owned.has(`${shape.kind}:${key}`))
|
|
10923
|
+
issues.push({ code: PlanWordIssueCode.InventedFact, path, message: `"${m.trim()}" looks like a ${shape.kind} the owner never gave — leave it out` });
|
|
10924
|
+
}
|
|
10925
|
+
}
|
|
10926
|
+
}
|
|
10927
|
+
for (const [key, value] of Object.entries(plan.business?.facts ?? {})) {
|
|
10928
|
+
const own = ctx.facts[key];
|
|
10929
|
+
if (!FACT_KEYS.includes(key))
|
|
10930
|
+
continue;
|
|
10931
|
+
if (typeof own !== 'string' || normalise(own).trim() !== normalise(String(value)).trim()) {
|
|
10932
|
+
issues.push({ code: PlanWordIssueCode.FactNotTheOwners, path: `business.facts.${key}`, message: own ? `must be the owner's own words: "${own}"` : 'the owner did not give it — leave it out (it becomes a placeholder)' });
|
|
10933
|
+
}
|
|
10934
|
+
}
|
|
10935
|
+
return issues;
|
|
10936
|
+
}
|
|
10937
|
+
|
|
10938
|
+
/**
|
|
10939
|
+
* THE Publishable gate (AI plan §3e, M1-7): ONE pure function over a snapshot of a site — its pages, globals, the
|
|
10940
|
+
* connectors' health, the look's token rows, what a build left to fill — run by the Publish dialog, the builder's
|
|
10941
|
+
* Needs attention and the server on publish (iox-cms refuses with the same findings). Works on any site, generated
|
|
10942
|
+
* or hand-built. Severity is decision D8: an unfilled fact, a policy placeholder or an action that points at nothing
|
|
10943
|
+
* BLOCKS; samples, copy placeholders and stock photos ASK ("Publish anyway"); the rest WARN. Design advice
|
|
10944
|
+
* (harness-core `lintDesign`) is not one of the eight — the caller adds it, and it never blocks.
|
|
10945
|
+
*/
|
|
10946
|
+
var SiteCheckId;
|
|
10947
|
+
(function (SiteCheckId) {
|
|
10948
|
+
SiteCheckId["Navigation"] = "navigation";
|
|
10949
|
+
SiteCheckId["Forms"] = "forms";
|
|
10950
|
+
SiteCheckId["Consent"] = "consent";
|
|
10951
|
+
SiteCheckId["Seo"] = "seo";
|
|
10952
|
+
SiteCheckId["Facts"] = "facts";
|
|
10953
|
+
SiteCheckId["Accessibility"] = "accessibility";
|
|
10954
|
+
SiteCheckId["Tokens"] = "tokens";
|
|
10955
|
+
SiteCheckId["Actions"] = "actions";
|
|
10956
|
+
})(SiteCheckId || (SiteCheckId = {}));
|
|
10957
|
+
/** The eight, in §3e's order. */
|
|
10958
|
+
const SITE_CHECKS = Object.values(SiteCheckId);
|
|
10959
|
+
var CheckSeverity;
|
|
10960
|
+
(function (CheckSeverity) {
|
|
10961
|
+
CheckSeverity["Block"] = "block";
|
|
10962
|
+
CheckSeverity["Ask"] = "ask";
|
|
10963
|
+
CheckSeverity["Warn"] = "warn";
|
|
10964
|
+
})(CheckSeverity || (CheckSeverity = {}));
|
|
10965
|
+
var SiteFindingCode;
|
|
10966
|
+
(function (SiteFindingCode) {
|
|
10967
|
+
SiteFindingCode["NoHeader"] = "no-header";
|
|
10968
|
+
SiteFindingCode["NoPhoneMenu"] = "no-phone-menu";
|
|
10969
|
+
SiteFindingCode["FormWithoutId"] = "form-without-id";
|
|
10970
|
+
SiteFindingCode["EmailNeedsMailer"] = "email-needs-mailer";
|
|
10971
|
+
SiteFindingCode["TrackerWithoutBanner"] = "tracker-without-banner";
|
|
10972
|
+
SiteFindingCode["NoTitle"] = "no-title";
|
|
10973
|
+
SiteFindingCode["NoDescription"] = "no-description";
|
|
10974
|
+
SiteFindingCode["UnfilledFact"] = "unfilled-fact";
|
|
10975
|
+
SiteFindingCode["CopyPlaceholder"] = "copy-placeholder";
|
|
10976
|
+
SiteFindingCode["SampleItems"] = "sample-items";
|
|
10977
|
+
SiteFindingCode["StockPhoto"] = "stock-photo";
|
|
10978
|
+
SiteFindingCode["NoPrivacyPage"] = "no-privacy-page";
|
|
10979
|
+
SiteFindingCode["PrivacyPagePlaceholder"] = "privacy-page-placeholder";
|
|
10980
|
+
SiteFindingCode["LowContrast"] = "low-contrast";
|
|
10981
|
+
SiteFindingCode["HeadingOrder"] = "heading-order";
|
|
10982
|
+
SiteFindingCode["ImageWithoutAlt"] = "image-without-alt";
|
|
10983
|
+
SiteFindingCode["LiteralColour"] = "literal-colour";
|
|
10984
|
+
SiteFindingCode["DeadLink"] = "dead-link";
|
|
10985
|
+
SiteFindingCode["DeadDialogTarget"] = "dead-dialog-target";
|
|
10986
|
+
SiteFindingCode["ActionNeedsConnector"] = "action-needs-connector";
|
|
10987
|
+
})(SiteFindingCode || (SiteFindingCode = {}));
|
|
10988
|
+
/** Each finding's severity — one table (D8), never decided at a call site. */
|
|
10989
|
+
const FINDING_SEVERITY = {
|
|
10990
|
+
[SiteFindingCode.NoHeader]: CheckSeverity.Warn,
|
|
10991
|
+
[SiteFindingCode.NoPhoneMenu]: CheckSeverity.Warn,
|
|
10992
|
+
[SiteFindingCode.FormWithoutId]: CheckSeverity.Block,
|
|
10993
|
+
[SiteFindingCode.EmailNeedsMailer]: CheckSeverity.Warn,
|
|
10994
|
+
[SiteFindingCode.TrackerWithoutBanner]: CheckSeverity.Warn,
|
|
10995
|
+
[SiteFindingCode.NoTitle]: CheckSeverity.Warn,
|
|
10996
|
+
[SiteFindingCode.NoDescription]: CheckSeverity.Warn,
|
|
10997
|
+
[SiteFindingCode.UnfilledFact]: CheckSeverity.Block,
|
|
10998
|
+
[SiteFindingCode.CopyPlaceholder]: CheckSeverity.Ask,
|
|
10999
|
+
[SiteFindingCode.SampleItems]: CheckSeverity.Ask,
|
|
11000
|
+
[SiteFindingCode.StockPhoto]: CheckSeverity.Ask,
|
|
11001
|
+
[SiteFindingCode.NoPrivacyPage]: CheckSeverity.Ask,
|
|
11002
|
+
[SiteFindingCode.PrivacyPagePlaceholder]: CheckSeverity.Block,
|
|
11003
|
+
[SiteFindingCode.LowContrast]: CheckSeverity.Warn,
|
|
11004
|
+
[SiteFindingCode.HeadingOrder]: CheckSeverity.Warn,
|
|
11005
|
+
[SiteFindingCode.ImageWithoutAlt]: CheckSeverity.Warn,
|
|
11006
|
+
[SiteFindingCode.LiteralColour]: CheckSeverity.Warn,
|
|
11007
|
+
[SiteFindingCode.DeadLink]: CheckSeverity.Block,
|
|
11008
|
+
[SiteFindingCode.DeadDialogTarget]: CheckSeverity.Block,
|
|
11009
|
+
[SiteFindingCode.ActionNeedsConnector]: CheckSeverity.Block,
|
|
11010
|
+
};
|
|
11011
|
+
const CHECK_OF = {
|
|
11012
|
+
[SiteFindingCode.NoHeader]: SiteCheckId.Navigation,
|
|
11013
|
+
[SiteFindingCode.NoPhoneMenu]: SiteCheckId.Navigation,
|
|
11014
|
+
[SiteFindingCode.FormWithoutId]: SiteCheckId.Forms,
|
|
11015
|
+
[SiteFindingCode.EmailNeedsMailer]: SiteCheckId.Forms,
|
|
11016
|
+
[SiteFindingCode.TrackerWithoutBanner]: SiteCheckId.Consent,
|
|
11017
|
+
[SiteFindingCode.NoTitle]: SiteCheckId.Seo,
|
|
11018
|
+
[SiteFindingCode.NoDescription]: SiteCheckId.Seo,
|
|
11019
|
+
[SiteFindingCode.UnfilledFact]: SiteCheckId.Facts,
|
|
11020
|
+
[SiteFindingCode.CopyPlaceholder]: SiteCheckId.Facts,
|
|
11021
|
+
[SiteFindingCode.SampleItems]: SiteCheckId.Facts,
|
|
11022
|
+
[SiteFindingCode.StockPhoto]: SiteCheckId.Facts,
|
|
11023
|
+
[SiteFindingCode.NoPrivacyPage]: SiteCheckId.Facts,
|
|
11024
|
+
[SiteFindingCode.PrivacyPagePlaceholder]: SiteCheckId.Facts,
|
|
11025
|
+
[SiteFindingCode.LowContrast]: SiteCheckId.Accessibility,
|
|
11026
|
+
[SiteFindingCode.HeadingOrder]: SiteCheckId.Accessibility,
|
|
11027
|
+
[SiteFindingCode.ImageWithoutAlt]: SiteCheckId.Accessibility,
|
|
11028
|
+
[SiteFindingCode.LiteralColour]: SiteCheckId.Tokens,
|
|
11029
|
+
[SiteFindingCode.DeadLink]: SiteCheckId.Actions,
|
|
11030
|
+
[SiteFindingCode.DeadDialogTarget]: SiteCheckId.Actions,
|
|
11031
|
+
[SiteFindingCode.ActionNeedsConnector]: SiteCheckId.Actions,
|
|
11032
|
+
};
|
|
11033
|
+
const LINKING_TYPES = new Set(['Link', 'Heading', 'Button', 'ButtonBlock', 'LinkedContainer', 'Image']);
|
|
11034
|
+
const PAGE_LINK = 'page';
|
|
11035
|
+
const PERSONAL_CONTROLS = new Set([ControlType.Email, ControlType.Tel, ControlType.Text, ControlType.Textarea]);
|
|
11036
|
+
const PRIVACY_PAGE = /privacy|פרטיות|מדיניות/i;
|
|
11037
|
+
const MAILER = 'mailer';
|
|
11038
|
+
const EMAIL_DELIVERY = 'email';
|
|
11039
|
+
const LITERAL_COLOUR = /#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i;
|
|
11040
|
+
const COLOUR_TRAITS = /color|Color|^background$|boxShadow|textShadow/;
|
|
11041
|
+
const HEADING_LEVEL = /^h([1-6])$/;
|
|
11042
|
+
const DIALOG_TYPE = 'Dialog';
|
|
11043
|
+
const SELF = 'self';
|
|
11044
|
+
/** A route pattern (`/work/:slug`) matches a concrete path (`/work/blue-bowl`). */
|
|
11045
|
+
function routeMatches(pattern, path) {
|
|
11046
|
+
const a = pattern.split('/').filter(Boolean);
|
|
11047
|
+
const b = String(path ?? '').split(/[?#]/)[0].split('/').filter(Boolean);
|
|
11048
|
+
return a.length === b.length && a.every((seg, i) => seg.startsWith(':') || seg === b[i]);
|
|
11049
|
+
}
|
|
11050
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
|
|
11051
|
+
/** A stored SEO field (a string, or `{ override, binding, auto }`) has a value, or may be derived. */
|
|
11052
|
+
function seoField(raw, derivable) {
|
|
11053
|
+
if (nonEmpty(raw))
|
|
11054
|
+
return true;
|
|
11055
|
+
if (raw && typeof raw === 'object') {
|
|
11056
|
+
const v = raw;
|
|
11057
|
+
if (nonEmpty(v.override) || v.binding)
|
|
11058
|
+
return true;
|
|
11059
|
+
if (v.auto === false)
|
|
11060
|
+
return false;
|
|
11061
|
+
}
|
|
11062
|
+
return derivable;
|
|
11063
|
+
}
|
|
11064
|
+
function hasContent(layout, type) {
|
|
11065
|
+
let found = false;
|
|
11066
|
+
walkSerialized(layout, n => {
|
|
11067
|
+
if (n.type !== type || found)
|
|
11068
|
+
return;
|
|
11069
|
+
found = nonEmpty(n.props['content']) || n.bindings.some(b => b.prop === 'content');
|
|
11070
|
+
});
|
|
11071
|
+
return found;
|
|
11072
|
+
}
|
|
11073
|
+
function add(ctx, code, message, where = {}) {
|
|
11074
|
+
ctx.out.push({ check: CHECK_OF[code], severity: FINDING_SEVERITY[code], code, message, ...where });
|
|
11075
|
+
}
|
|
11076
|
+
/** Every node of a page or a global, with where it lives. */
|
|
11077
|
+
function eachNode(snapshot, pages, visit) {
|
|
11078
|
+
for (const page of pages)
|
|
11079
|
+
walkSerialized(page.layout, n => visit(n, { pageId: page.id }));
|
|
11080
|
+
for (const global of snapshot.globals) {
|
|
11081
|
+
const owner = global.globalPosition === PatternGlobalPosition.Before ? 'header' : 'footer';
|
|
11082
|
+
walkSerialized([global], n => visit(n, { owner }));
|
|
11083
|
+
}
|
|
11084
|
+
}
|
|
11085
|
+
function checkNavigation(ctx, snapshot) {
|
|
11086
|
+
const header = snapshot.globals.find(g => g.globalPosition === PatternGlobalPosition.Before);
|
|
11087
|
+
if (!header)
|
|
11088
|
+
return add(ctx, SiteFindingCode.NoHeader, 'The site has no header, so visitors have no menu to move around with.');
|
|
11089
|
+
if (!navShapeOf(snapshot.globals))
|
|
11090
|
+
add(ctx, SiteFindingCode.NoPhoneMenu, 'The header has no phone menu — on a phone its links are hard to reach.', { owner: 'header', nodeId: header.id });
|
|
11091
|
+
}
|
|
11092
|
+
function checkForms(ctx, snapshot, pages) {
|
|
11093
|
+
let personal = false;
|
|
11094
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
11095
|
+
if (n.type !== FORM_NODE_TYPE)
|
|
11096
|
+
return;
|
|
11097
|
+
const spec = collectFormSpec(n);
|
|
11098
|
+
if (!spec || !nonEmpty(spec.formId))
|
|
11099
|
+
add(ctx, SiteFindingCode.FormWithoutId, 'A form has no id, so what visitors send is never stored.', { ...where, nodeId: n.id });
|
|
11100
|
+
if (spec?.actions.some(a => a.type === EMAIL_DELIVERY) && snapshot.connectors[MAILER] !== true) {
|
|
11101
|
+
add(ctx, SiteFindingCode.EmailNeedsMailer, 'A form emails what visitors send, but Email is not connected — it is stored and never sent.', { ...where, nodeId: n.id });
|
|
11102
|
+
}
|
|
11103
|
+
walkSerialized(n.children ?? [], f => {
|
|
11104
|
+
if (f.type === FIELD_NODE_TYPE && PERSONAL_CONTROLS.has(String(f.props['control'] ?? ControlType.Text)))
|
|
11105
|
+
personal = true;
|
|
11106
|
+
});
|
|
11107
|
+
});
|
|
11108
|
+
return personal;
|
|
11109
|
+
}
|
|
11110
|
+
function checkConsent(ctx, snapshot) {
|
|
11111
|
+
if (!snapshot.trackers?.length)
|
|
11112
|
+
return;
|
|
11113
|
+
let banner = false;
|
|
11114
|
+
for (const g of snapshot.globals)
|
|
11115
|
+
walkSerialized([g], n => { if (n.type === DIALOG_TYPE && n.props['consentRole'] === true)
|
|
11116
|
+
banner = true; });
|
|
11117
|
+
if (!banner)
|
|
11118
|
+
add(ctx, SiteFindingCode.TrackerWithoutBanner, `${snapshot.trackers.join(', ')} is connected, but the site has no cookie banner — it stays off for every visitor.`);
|
|
11119
|
+
}
|
|
11120
|
+
function checkSeo(ctx, pages) {
|
|
11121
|
+
for (const page of pages) {
|
|
11122
|
+
const seo = (page.seo ?? {});
|
|
11123
|
+
if (!seoField(seo['title'], hasContent(page.layout, 'Heading')))
|
|
11124
|
+
add(ctx, SiteFindingCode.NoTitle, `"${page.name}" has no title for search results.`, { pageId: page.id });
|
|
11125
|
+
if (!seoField(seo['description'], hasContent(page.layout, 'Text')))
|
|
11126
|
+
add(ctx, SiteFindingCode.NoDescription, `"${page.name}" has no description for search results.`, { pageId: page.id });
|
|
11127
|
+
}
|
|
11128
|
+
}
|
|
11129
|
+
function checkFacts(ctx, snapshot, pages, personal) {
|
|
11130
|
+
const inScope = (p) => !p.pageId || pages.some(page => page.id === p.pageId);
|
|
11131
|
+
for (const p of (snapshot.placeholders ?? []).filter(p => !p.filled && inScope(p))) {
|
|
11132
|
+
const where = { pageId: p.pageId, nodeId: p.nodeId, ...(p.pageId ? {} : { owner: p.owner }) };
|
|
11133
|
+
if (blocksPublish(p.kind))
|
|
11134
|
+
add(ctx, SiteFindingCode.UnfilledFact, `"${p.text || p.fact || p.kind}" is still blank — fill it in before the site goes live.`, where);
|
|
11135
|
+
else if (p.kind === PlaceholderKind.Copy)
|
|
11136
|
+
add(ctx, SiteFindingCode.CopyPlaceholder, 'Some words are still the template\'s.', where);
|
|
11137
|
+
}
|
|
11138
|
+
for (const s of snapshot.samples ?? [])
|
|
11139
|
+
add(ctx, SiteFindingCode.SampleItems, `"${s.label ?? s.id}" is a sample — replace or remove it.`, { nodeId: s.id });
|
|
11140
|
+
for (const s of (snapshot.stock ?? []).filter(inScope))
|
|
11141
|
+
add(ctx, SiteFindingCode.StockPhoto, 'A stock photo stands in for one of yours.', { pageId: s.pageId, nodeId: s.nodeId, owner: s.owner });
|
|
11142
|
+
if (!personal)
|
|
11143
|
+
return;
|
|
11144
|
+
const privacy = snapshot.pages.find(p => PRIVACY_PAGE.test(p.route) || PRIVACY_PAGE.test(p.name));
|
|
11145
|
+
if (!privacy)
|
|
11146
|
+
return add(ctx, SiteFindingCode.NoPrivacyPage, 'A form asks for personal details, and the site has no privacy page saying what you do with them.');
|
|
11147
|
+
if ((snapshot.placeholders ?? []).some(p => p.pageId === privacy.id && !p.filled)) {
|
|
11148
|
+
add(ctx, SiteFindingCode.PrivacyPagePlaceholder, 'The privacy page still holds template words — only you can write it.', { pageId: privacy.id });
|
|
11149
|
+
}
|
|
11150
|
+
}
|
|
11151
|
+
function checkAccessibility(ctx, snapshot, pages) {
|
|
11152
|
+
for (const mode of snapshot.design?.modes ?? []) {
|
|
11153
|
+
const failing = auditDesignContrast(snapshot.design.rows, mode).filter(f => !f.pass);
|
|
11154
|
+
if (failing.length)
|
|
11155
|
+
add(ctx, SiteFindingCode.LowContrast, `${failing.length} text and background pair(s) are hard to read in ${mode} mode.`);
|
|
11156
|
+
}
|
|
11157
|
+
for (const page of pages) {
|
|
11158
|
+
let last = 0;
|
|
11159
|
+
let h1 = 0;
|
|
11160
|
+
walkSerialized(page.layout, n => {
|
|
11161
|
+
const level = n.type === 'Heading' ? HEADING_LEVEL.exec(String(n.props['level'] ?? ''))?.[1] : undefined;
|
|
11162
|
+
if (!level)
|
|
11163
|
+
return;
|
|
11164
|
+
const l = Number(level);
|
|
11165
|
+
if (l === 1)
|
|
11166
|
+
h1++;
|
|
11167
|
+
if (last && l > last + 1)
|
|
11168
|
+
add(ctx, SiteFindingCode.HeadingOrder, `A heading on "${page.name}" skips a level (h${last} → h${l}).`, { pageId: page.id, nodeId: n.id });
|
|
11169
|
+
last = l;
|
|
11170
|
+
});
|
|
11171
|
+
if (h1 > 1)
|
|
11172
|
+
add(ctx, SiteFindingCode.HeadingOrder, `"${page.name}" has ${h1} main headings (h1); one is clearer.`, { pageId: page.id });
|
|
11173
|
+
}
|
|
11174
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
11175
|
+
if (n.type !== 'Image')
|
|
11176
|
+
return;
|
|
11177
|
+
const decorative = n.props['alt'] === '' && n.props['decorative'] === true;
|
|
11178
|
+
const bound = n.bindings.some(b => b.prop === 'alt');
|
|
11179
|
+
if (!decorative && !bound && !nonEmpty(n.props['alt']))
|
|
11180
|
+
add(ctx, SiteFindingCode.ImageWithoutAlt, 'A picture has no description for people who cannot see it.', { ...where, nodeId: n.id });
|
|
11181
|
+
});
|
|
11182
|
+
}
|
|
11183
|
+
function checkTokens(ctx, snapshot, pages) {
|
|
11184
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
11185
|
+
if (!n.elementStyle)
|
|
11186
|
+
return;
|
|
11187
|
+
const maps = [n.styleProps, ...Object.values(n.stateStyles ?? {}), ...Object.values(n.responsive ?? {}).map(l => l?.styleProps)];
|
|
11188
|
+
const literal = maps.some(map => Object.entries(map ?? {}).some(([k, v]) => COLOUR_TRAITS.test(k) && LITERAL_COLOUR.test(String(v)) && !(k === 'borderColor' && n.styleProps['borderWidth'] === '0px')));
|
|
11189
|
+
if (literal)
|
|
11190
|
+
add(ctx, SiteFindingCode.LiteralColour, 'An element paints a fixed colour, so a new look will not reach it.', { ...where, nodeId: n.id });
|
|
11191
|
+
});
|
|
11192
|
+
}
|
|
11193
|
+
function checkActions(ctx, snapshot, pages) {
|
|
11194
|
+
const routeExists = (route) => typeof route === 'string' && ctx.routes.some(r => routeMatches(r, route));
|
|
11195
|
+
eachNode(snapshot, pages, (n, where) => {
|
|
11196
|
+
if (LINKING_TYPES.has(n.type) && n.props['linkType'] === PAGE_LINK && !routeExists(n.props['pageRoute'])) {
|
|
11197
|
+
add(ctx, SiteFindingCode.DeadLink, `"${String(n.props['label'] ?? n.props['content'] ?? n.label ?? n.type)}" links to ${String(n.props['pageRoute'] || 'no page')}, which is not a page of the site.`, { ...where, nodeId: n.id });
|
|
11198
|
+
}
|
|
11199
|
+
for (const ix of (n.interactions ?? [])) {
|
|
11200
|
+
for (const action of ix?.actions ?? []) {
|
|
11201
|
+
if (!action || action.disabled)
|
|
11202
|
+
continue;
|
|
11203
|
+
const descriptor = ELEMENT_ACTION_DESCRIPTORS.find(d => d.type === action.type);
|
|
11204
|
+
if (!descriptor)
|
|
11205
|
+
continue;
|
|
11206
|
+
const types = descriptor.target?.types;
|
|
11207
|
+
if (descriptor.target?.required && action.target !== SELF) {
|
|
11208
|
+
const targetType = action.target ? ctx.nodeIds.get(action.target) : undefined;
|
|
11209
|
+
if (!targetType || (types && !types.includes(targetType))) {
|
|
11210
|
+
add(ctx, SiteFindingCode.DeadDialogTarget, `"${n.label ?? n.type}" opens something that is no longer on the site.`, { ...where, nodeId: n.id });
|
|
11211
|
+
}
|
|
11212
|
+
}
|
|
11213
|
+
if (action.type === 'navigate' && action.params?.['navigateTo'] === PAGE_LINK && !routeExists(action.params?.['page'])) {
|
|
11214
|
+
add(ctx, SiteFindingCode.DeadLink, `"${n.label ?? n.type}" goes to a page the site does not have.`, { ...where, nodeId: n.id });
|
|
11215
|
+
}
|
|
11216
|
+
// A connector the snapshot does not say is usable is not: unknown is never "fine".
|
|
11217
|
+
const connectors = descriptor.connector ? { [descriptor.connector]: snapshot.connectors[descriptor.connector] === true } : {};
|
|
11218
|
+
const availability = resolveActionAvailability(descriptor, { connectors });
|
|
11219
|
+
if (availability.state === 'connector-missing') {
|
|
11220
|
+
add(ctx, SiteFindingCode.ActionNeedsConnector, `"${n.label ?? n.type}" needs ${availability.connector} connected.`, { ...where, nodeId: n.id });
|
|
11221
|
+
}
|
|
11222
|
+
}
|
|
11223
|
+
}
|
|
11224
|
+
});
|
|
11225
|
+
}
|
|
11226
|
+
/** Run the eight checks. `pageId` narrows the page checks to the page being published; site-wide ones always run. */
|
|
11227
|
+
function auditSite(snapshot, options = {}) {
|
|
11228
|
+
const pages = options.pageId ? snapshot.pages.filter(p => p.id === options.pageId) : snapshot.pages;
|
|
11229
|
+
const nodeIds = new Map();
|
|
11230
|
+
eachNode(snapshot, snapshot.pages, n => nodeIds.set(n.id, n.type));
|
|
11231
|
+
const ctx = { out: [], routes: snapshot.pages.map(p => p.route), nodeIds };
|
|
11232
|
+
checkNavigation(ctx, snapshot);
|
|
11233
|
+
const personal = checkForms(ctx, snapshot, pages);
|
|
11234
|
+
checkConsent(ctx, snapshot);
|
|
11235
|
+
checkSeo(ctx, pages);
|
|
11236
|
+
checkFacts(ctx, snapshot, pages, personal);
|
|
11237
|
+
checkAccessibility(ctx, snapshot, pages);
|
|
11238
|
+
checkTokens(ctx, snapshot, pages);
|
|
11239
|
+
checkActions(ctx, snapshot, pages);
|
|
11240
|
+
const checks = SITE_CHECKS.map(id => {
|
|
11241
|
+
const findings = ctx.out.filter(f => f.check === id);
|
|
11242
|
+
return { id, passed: !findings.some(f => f.severity !== CheckSeverity.Warn), findings };
|
|
11243
|
+
});
|
|
11244
|
+
return {
|
|
11245
|
+
checks,
|
|
11246
|
+
blocking: ctx.out.filter(f => f.severity === CheckSeverity.Block).length,
|
|
11247
|
+
asking: ctx.out.filter(f => f.severity === CheckSeverity.Ask).length,
|
|
11248
|
+
};
|
|
11249
|
+
}
|
|
11250
|
+
/** What a publish request must acknowledge: every ASK finding's code (a BLOCK cannot be acknowledged). */
|
|
11251
|
+
function requiredAcknowledgements(audit) {
|
|
11252
|
+
return [...new Set(audit.checks.flatMap(c => c.findings).filter(f => f.severity === CheckSeverity.Ask).map(f => f.code))];
|
|
11253
|
+
}
|
|
11254
|
+
/** The publish verdict: blocked (never acknowledgeable), needs these acknowledgements, or free to go. */
|
|
11255
|
+
function publishVerdict(audit, acknowledged = []) {
|
|
11256
|
+
const missing = requiredAcknowledgements(audit).filter(code => !acknowledged.includes(code));
|
|
11257
|
+
const blocked = audit.blocking > 0;
|
|
11258
|
+
return { ok: !blocked && missing.length === 0, blocked, missing };
|
|
11259
|
+
}
|
|
11260
|
+
|
|
11261
|
+
/**
|
|
11262
|
+
* Stock photos in a built site (AI plan §3g / §3m, I2): a FREE picture from the platform's curated library, copied
|
|
11263
|
+
* into the studio's own storage (never hot-linked), placed only in an EMPTY picture slot of a section that may show
|
|
11264
|
+
* one — never the owner's work (a collection, a detail page, a gallery: what the nine portfolio types sell is theirs),
|
|
11265
|
+
* never a person (testimonials, the team), never anywhere when the business type says `stock: false`. Its alt text is
|
|
11266
|
+
* the provider's description of what it shows (never a claim), and the credit line its licence requires is placed in
|
|
11267
|
+
* the footer. Pure: the build step does the copying.
|
|
11268
|
+
*/
|
|
11269
|
+
/** Where a stock photo comes from (the library's provider). */
|
|
11270
|
+
var StockProviderId;
|
|
11271
|
+
(function (StockProviderId) {
|
|
11272
|
+
StockProviderId["Magnific"] = "magnific";
|
|
11273
|
+
})(StockProviderId || (StockProviderId = {}));
|
|
11274
|
+
/** The section families a stock photo may stand in for a picture in. */
|
|
11275
|
+
const STOCK_FAMILIES = new Set(['hero', 'feature', 'content', 'story', 'cta', 'contact', 'header']);
|
|
11276
|
+
/** The empty picture slots of a composed site a stock photo may fill, in page and section order. */
|
|
11277
|
+
function stockSlots(plan, composed) {
|
|
11278
|
+
const empty = new Set(composed.placeholders.filter(p => p.kind === PlaceholderKind.Image).map(p => p.nodeId));
|
|
11279
|
+
const out = [];
|
|
11280
|
+
composed.pages.forEach((page, p) => {
|
|
11281
|
+
page.layout.forEach((root, s) => {
|
|
11282
|
+
const pattern = plan.pages[p]?.sections[s]?.pattern;
|
|
11283
|
+
const family = patternById(pattern)?.family;
|
|
11284
|
+
if (!pattern || !family || !STOCK_FAMILIES.has(family))
|
|
11285
|
+
return;
|
|
11286
|
+
walkSerialized([root], n => {
|
|
11287
|
+
if (n.type === 'Image' && empty.has(n.id) && !n.bindings.length)
|
|
11288
|
+
out.push({ nodeId: n.id, owner: page.key, pattern });
|
|
11289
|
+
});
|
|
11290
|
+
});
|
|
11291
|
+
});
|
|
11292
|
+
return out;
|
|
11293
|
+
}
|
|
11294
|
+
/** Which photo goes where: the library's photos in order, one per slot, never twice; none when the type says no stock. */
|
|
11295
|
+
function planStockPhotos(slots, photos, stockAllowed) {
|
|
11296
|
+
if (!stockAllowed)
|
|
11297
|
+
return [];
|
|
11298
|
+
return slots.slice(0, photos.length).map((slot, i) => ({ ...slot, photo: photos[i] }));
|
|
11299
|
+
}
|
|
11300
|
+
/** A placed photo's copy in the studio's storage: the key the build writes it under. */
|
|
11301
|
+
function stockCopyKey(photo) {
|
|
11302
|
+
return `media/stock/${photo.key.replace(/[^a-zA-Z0-9._-]+/g, '-')}`;
|
|
11303
|
+
}
|
|
11304
|
+
/** Put the studio's copy of each photo into its slot (src + the provider's alt). Returns the nodes changed. */
|
|
11305
|
+
function applyStockPhotos(trees, placed) {
|
|
11306
|
+
const byId = new Map(placed.map(p => [p.nodeId, p]));
|
|
11307
|
+
let changed = 0;
|
|
11308
|
+
walkSerialized(trees, n => {
|
|
11309
|
+
const p = byId.get(n.id);
|
|
11310
|
+
if (!p || n.type !== 'Image')
|
|
11311
|
+
return;
|
|
11312
|
+
n.props['src'] = p.url;
|
|
11313
|
+
n.props['alt'] = p.alt;
|
|
11314
|
+
changed++;
|
|
11315
|
+
});
|
|
11316
|
+
return changed;
|
|
11317
|
+
}
|
|
11318
|
+
/** The credit lines a set of photos needs — one per distinct line (a free Magnific photo: "Designed by Magnific"). */
|
|
11319
|
+
function stockCreditLines(photos) {
|
|
11320
|
+
const seen = new Map();
|
|
11321
|
+
for (const p of photos)
|
|
11322
|
+
if (!seen.has(p.credit.line))
|
|
11323
|
+
seen.set(p.credit.line, p.credit.lineUrl);
|
|
11324
|
+
return [...seen.entries()].map(([line, url]) => ({ line, url }));
|
|
11325
|
+
}
|
|
11326
|
+
/** The footer node id of a credit line (stable — a build that runs twice finds it). */
|
|
11327
|
+
const stockCreditNodeId = (index) => `stock-credit-${index + 1}`;
|
|
11328
|
+
/**
|
|
11329
|
+
* The credit line, as the builder would create a Link (a plain node the owner may move or restyle): the licence's words,
|
|
11330
|
+
* linking where it asks, opening in a new tab. Placed at the end of the footer — the place the licence names for a site.
|
|
11331
|
+
*/
|
|
11332
|
+
function stockCreditNode(index, credit) {
|
|
11333
|
+
return createSerializedNode(stockCreditNodeId(index), 'Link', { label: credit.line, linkType: 'external', url: credit.url, target: '_blank' }, 'link');
|
|
11334
|
+
}
|
|
11335
|
+
/** Append the credit lines to the footer root (once each). Returns how many it added. */
|
|
11336
|
+
function addStockCredits(footer, credits) {
|
|
11337
|
+
footer.children ??= [];
|
|
11338
|
+
let added = 0;
|
|
11339
|
+
credits.forEach((credit, i) => {
|
|
11340
|
+
const id = stockCreditNodeId(i);
|
|
11341
|
+
if (footer.children.some(c => c.id === id))
|
|
11342
|
+
return;
|
|
11343
|
+
footer.children.push(stockCreditNode(i, credit));
|
|
11344
|
+
added++;
|
|
11345
|
+
});
|
|
11346
|
+
return added;
|
|
11347
|
+
}
|
|
11348
|
+
|
|
10125
11349
|
/**
|
|
10126
11350
|
* @vectoriox/iox-ui/generation-core — the shared, FRAMEWORK-FREE site-plan core (design-system plan
|
|
10127
11351
|
* §6b, W1): the Site Plan shape + its validator, the pattern library and the Studio template (both
|
|
@@ -10138,5 +11362,5 @@ var AiCallStatus;
|
|
|
10138
11362
|
* Generated bundle index. Do not edit.
|
|
10139
11363
|
*/
|
|
10140
11364
|
|
|
10141
|
-
export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_NEEDS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiGenerationStage, AiKeyMode, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CREDITS_PER_USD, CardBindTarget, ComposeError, ComposeIssueCode, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, FACT_KEYS, FINAL_GENERATION_STAGES, GlobalSlot, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanDataSourceMode, PlanDataSourceType, PreflightRefusalCode, RelativeOp, SAMPLE_IMAGE_REF, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_TEMPLATES, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, addBarItem, altSourceScopes, assertStyleSlots, assertValidSitePlan, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, costUsd, createSerializedNode, creditsOfUsd, describePlanData, entranceAction, entranceInteraction, estimateStepCredits, failedBuildStep, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, parseSampleImageRef, pathAt, patternById, patternNeedsBinding, patternNodes, patternOptionalParts, patternScopes, patternSlots, patternTakesBinding, phoneMenuPages, planFromTemplate, planNavShape, releasedLayoutDefaults, removeBarItem, requiredShapeKeys, routeParams, sampleImageRef, sectionEntrances, setStyleProps, shapeKeys, siteNavPages, staggerDelay, validateSitePlan, walkSerialized };
|
|
11365
|
+
export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiGenerationStage, AiKeyMode, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, FACT_KEYS, FINAL_GENERATION_STAGES, FINDING_SEVERITY, GlobalSlot, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PLAN_EDITS_ANSWER_SHAPE, PLAN_EDITS_SCHEMA_NAME, PLAN_EDIT_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanWordIssueCode, PreflightRefusalCode, RelativeOp, SAMPLE_IMAGE_REF, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_PATTERNS, SITE_PLAN_SCHEMA_NAME, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SitePlanWireKey, SiteTemplateError, SiteTemplateId, StepState, StockProviderId, addBarItem, addStockCredits, altSourceScopes, applyPlanEdits, applyStockPhotos, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, contentTypeFromWire, contentTypeJsonSchema, costUsd, createSerializedNode, creditsOfUsd, dataSourceJsonSchema, describePlanData, describeShapeKey, entranceAction, entranceInteraction, estimateStepCredits, failedBuildStep, fieldJsonSchema, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, pageJsonSchema, parseSampleImageRef, pathAt, patternById, patternNeedsBinding, patternNodes, patternOptionalParts, patternScopes, patternSlots, patternTakesBinding, phoneMenuPages, planEditsFromWire, planEditsJsonSchema, planFromTemplate, planNavShape, planStockPhotos, planWordIssues, publishVerdict, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, routeMatches, routeParams, sampleImageRef, sectionEntrances, sectionFromWire, sectionJsonSchema, sectionPatternIds, setStyleProps, shapeKeys, siteNavPages, sitePlanFromWire, sitePlanJsonSchema, sitePlanToWire, staggerDelay, stockCopyKey, stockCreditLines, stockCreditNode, stockCreditNodeId, stockSlots, validatePlanEdits, validateSitePlan, walkSerialized, wordPattern, writtenTexts };
|
|
10142
11366
|
//# sourceMappingURL=vectoriox-iox-ui-generation-core.mjs.map
|