@vectoriox/iox-ui 5.63.0 → 5.65.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/actions-core/index.d.ts +62 -2
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +80 -1
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +174 -34
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/generation-core/index.d.ts +29 -5
- package/package.json +1 -1
- package/src/actions-core/dialog-motion.ts +104 -0
- package/src/actions-core/public-api.ts +1 -0
- package/src/generation-core/compose-site.ts +21 -23
- package/src/generation-core/nav-shape.ts +31 -11
- package/src/generation-core/plan-derived.ts +84 -0
- package/src/generation-core/plan-edits.ts +8 -3
- package/src/generation-core/plan-wire.ts +9 -4
- package/src/generation-core/public-api.ts +2 -0
- package/src/generation-core/recipe-fields.ts +49 -0
- package/src/generation-core/validate-site-plan.ts +15 -2
- package/styles/iox-design.css +67 -8
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AnimateId, DesignChoices, DesignTokenLike } from '@vectoriox/iox-ui/design-core';
|
|
2
2
|
import { SeoRawDoc } from '@vectoriox/iox-ui/seo-core';
|
|
3
|
+
import { ContentFieldLike } from '@vectoriox/iox-ui/composite-core';
|
|
3
4
|
|
|
4
5
|
declare const SPEC_SITE_PLAN_SHAPE: {
|
|
5
6
|
readonly plan: {
|
|
@@ -5744,6 +5745,10 @@ declare function buildStepFacts(plan: SitePlan, actions?: Readonly<Record<string
|
|
|
5744
5745
|
* - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
|
|
5745
5746
|
* the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
|
|
5746
5747
|
* So the format is the same for every studio and every run — one compiled grammar, cached by the provider.
|
|
5748
|
+
* - **What follows from the pages is derived, never asked** (`plan-derived.ts`, vectoriox/iox-cms#49): a per-item page
|
|
5749
|
+
* reading the next item gets its `$siblings.next` source — the structure, asked first and then fixed, cannot know the
|
|
5750
|
+
* pages need it, and the pages cannot add a source. `planToWire` writes every source the plan holds, a derived one
|
|
5751
|
+
* too (it is the plan's: a change's brief shows it, and a plan → wire → plan round trip stays exact).
|
|
5747
5752
|
*
|
|
5748
5753
|
* ONE source: every wire key names the plan keys it carries (`refs`) and is described in the plan table's own words, and
|
|
5749
5754
|
* every plan key is either carried or derived (`planWireGaps()`); a key the plan shape gains breaks the builder until it
|
|
@@ -5827,7 +5832,7 @@ type PlanWireParts = Partial<Record<PlanPart, unknown>>;
|
|
|
5827
5832
|
* A model's plan (its parts, on the wire) + what the generation decides (the look, the run, the language, the owner's
|
|
5828
5833
|
* facts) → the Site Plan. Pure. Translates, never repairs: whatever an answer got wrong is still wrong, for
|
|
5829
5834
|
* `validateSitePlan` — a part that is not an object is the result itself, a part not written yet leaves its keys out
|
|
5830
|
-
* (the validator then names them, at their own paths).
|
|
5835
|
+
* (the validator then names them, at their own paths). What follows from the pages alone is added (`withDerivedSources`).
|
|
5831
5836
|
*/
|
|
5832
5837
|
declare function expandPlanWire(parts: PlanWireParts, context: PlanWireContext): SitePlan | unknown;
|
|
5833
5838
|
declare function fieldToWire(field: ContentFieldPlan): PlainObject;
|
|
@@ -6025,6 +6030,24 @@ declare enum AiCallStatus {
|
|
|
6025
6030
|
Interrupted = "interrupted"
|
|
6026
6031
|
}
|
|
6027
6032
|
|
|
6033
|
+
interface RecipeListBinds<T> {
|
|
6034
|
+
picks: T;
|
|
6035
|
+
/** What the fields lack (the recipe's `needs`), or null when they fill it. */
|
|
6036
|
+
gap: string | null;
|
|
6037
|
+
}
|
|
6038
|
+
/** The fields `recipe` binds from a listed type's `fields`, and what they lack. A recipe that lists nothing lacks nothing. */
|
|
6039
|
+
declare function recipeListBinds<T = Readonly<Record<string, string | null>>>(recipe: string | undefined, fields: ReadonlyArray<ContentFieldLike>): RecipeListBinds<T>;
|
|
6040
|
+
/** What a listed type with `fields` lacks for the pattern's recipes that repeat the section's binding — the first gap, or null. */
|
|
6041
|
+
declare function patternListGap(pattern: SitePattern, fields: ReadonlyArray<ContentFieldLike>): string | null;
|
|
6042
|
+
|
|
6043
|
+
/**
|
|
6044
|
+
* The data sources `plan` lacks and its pages need, in page order (appended after the plan's own). Tolerates any
|
|
6045
|
+
* value: what is not a well-formed plan gains nothing, for the validator to judge as written.
|
|
6046
|
+
*/
|
|
6047
|
+
declare function derivedDataSources(plan: unknown): DataSourcePlan[];
|
|
6048
|
+
/** `plan` with the data sources its pages need appended (`derivedDataSources`); the same object when it lacks none. */
|
|
6049
|
+
declare function withDerivedSources<T>(plan: T): T;
|
|
6050
|
+
|
|
6028
6051
|
/**
|
|
6029
6052
|
* What a structured-output request costs the provider's grammar compiler, counted the way the Claude API counts it
|
|
6030
6053
|
* (platform.claude.com/docs/en/build-with-claude/structured-outputs, "Schema complexity limits", read 2026-10-27):
|
|
@@ -6234,8 +6257,9 @@ declare function validatePlanEdits(edits: unknown): {
|
|
|
6234
6257
|
};
|
|
6235
6258
|
/**
|
|
6236
6259
|
* Apply a set of edits to a plan — all of them, or none (any issue refuses the set and returns the plan unchanged). The
|
|
6237
|
-
* sources only removed pages and sections read are dropped with them; sources nothing read before are kept
|
|
6238
|
-
*
|
|
6260
|
+
* sources only removed pages and sections read are dropped with them; sources nothing read before are kept; the sources
|
|
6261
|
+
* the pages now need and no model writes are derived (`derivedDataSources` — a page edit adding `project.next`, as the
|
|
6262
|
+
* plan wire's expansion does, vectoriox/iox-cms#49). The result still has to pass `validateSitePlan`.
|
|
6239
6263
|
*/
|
|
6240
6264
|
declare function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>): PlanEditResult;
|
|
6241
6265
|
/** Each part's answer format name. */
|
|
@@ -6554,5 +6578,5 @@ declare function stockCreditNode(index: number, credit: StockLink): SerializedNo
|
|
|
6554
6578
|
/** Append the credit lines to the footer root (once each). Returns how many it added. */
|
|
6555
6579
|
declare function addStockCredits(footer: SerializedNode, credits: ReadonlyArray<StockLink>): number;
|
|
6556
6580
|
|
|
6557
|
-
export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_PART_TOKENS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiFeature, AiGenerationStage, AiKeyMode, AiLimit, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CONTENT_ROLE_MEANINGS, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentRole, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, DEFAULT_SITE_LOCALE, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, EntranceSide, FACT_KEYS, FINAL_GENERATION_STAGES, FINDING_SEVERITY, GRAMMAR_TOO_LARGE_MESSAGE, GlobalSlot, HEBREW_PREFIXES, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PAGE_ARCHETYPES, PLAN_EDITS_ANSWER_SHAPES, PLAN_EDITS_PART_SCHEMA_NAMES, PLAN_EDIT_OP_PART, PLAN_EDIT_SHAPE, PLAN_EDIT_WIRE_KEYS, PLAN_PARTS, PLAN_PART_KEYS, PLAN_WIRE_DERIVED, PLAN_WIRE_PART_SCHEMA_NAMES, PLAN_WIRE_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanPart, PlanWireEntry, PlanWireShapeError, PlanWordIssueCode, PreflightRefusalCode, PublishRefusalCode, REQUIRED_CONTENT_ROLES, RelativeOp, SAMPLE_IMAGE_REF, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_LIBRARY, SITE_LOCALES, SITE_NEED_IDS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_ARCHETYPES, SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STOCK_PROVIDER_TERMS, STRICT_SCHEMA_BUDGET, STRICT_SCHEMA_LIMITS, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SiteLocale, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, StockProviderId, addBarItem, addStockCredits, aiStepParts, applyPlanEdits, applyStockPhotos, archetypeIssues, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, contentTypePreviewFields, contentTypeToWire, copyLengthOf, costUsd, createSerializedNode, creditsOfUsd, dataSourceToWire, describePlanData, describeShapeKey, entranceAction, entranceInteraction, estimateStepCredits, expandPlanWire, expandWireContentType, expandWireDataSource, expandWireEdit, expandWireField, expandWirePage, expandWireSection, failedBuildStep, fieldToWire, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, isRtlLocale, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, pageToWire, parsePatternPath, parseSampleImageRef, pathAt, patternById, patternHasForm, patternLinkSlots, patternManifest, patternNeedsBinding, patternNodes, patternOptionalParts, patternRoleNeeds, patternScopes, patternSlotBudgets, patternSlots, patternTakesBinding, phoneMenuPages, planEditOps, planEditToWire, planEditWireGaps, planEditWireKeys, planEditWireOps, planEditWireShape, planEditsFromWire, planEditsJsonSchema, planEditsToWire, planEntrances, planFromTemplate, planNavShape, planPartOfPath, planPartToWire, planStockPhotos, planToWire, planWireGaps, planWirePartJsonSchema, planWirePartShape, planWordIssues, publishRefusal, publishVerdict, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, roleField, routeMatches, routeParams, sampleImageRef, sectionEntrances, sectionPatternIds, sectionToWire, setStyleProps, shapeKeys, showcaseGlobals, showcasePages, showcasePlans, siteNavPages, slotBudget, stockCopyKey, stockCreditLine, stockCreditLines, stockCreditNode, stockCreditNodeId, stockSlots, strictSchemaComplexity, strictSchemaRefusal, typicalStepTokens, validatePlanEdits, validateSitePlan, walkSerialized, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema, wirePageJsonSchema, wireSectionJsonSchema, wordPattern, writtenTexts };
|
|
6558
|
-
export type { AddBarItemResult, AiEntitlementsView, AiMeter, AiModelEntry, AiRefusal, AiTokenCounts, AiTokenPrice, AuditPage, AuditPlaceholder, BarItemsEditResult, BuildNotice, BuildStepError, BuildStepFacts, BuildStepRecord, CmsDataSourcePlan, ComposeContext, ComposeIssue, ComposedDataSource, ComposedItem, ComposedNavHeader, ComposedPage, ComposedPlaceholder, ComposedRoute, ComposedSection, ComposedSite, ContentFieldPlan, ContentRoles, ContentSeedPlan, ContentTypePlan, CreateNodeOptions, CreatedRecord, DataSourcePlan, DynamicKeysAsList, EntranceAction, EntranceActionOptions, EntranceInteraction, EntranceMark, EntranceSpec, EntranceStamp, FactKey, JsonSchema, NavHeaderInput, NavHeaderOptions, NavPage, NavShapeOptions, NavShapeResult, NodeBaseline, PageArchetype, PageArchetypeId, PageArchetypeSlot, PageData, PageDataLine, PagePlan, PageSectionPlan, PageSeoPlan, PathIssue, PatternManifestEntry, PatternNode, PatternPath, PatternRoleNeed, PlainObject, PlanChange, PlanEdit, PlanEditIssue, PlanEditResult, PlanEditsAnswer, PlanEntrancesInput, PlanWireContext, PlanWireParts, PlanWordContext, PlanWordIssue, PreflightRefusal, PublishRefusal, RelativeDataSourcePlan, SectionEntrances, SectionInput, SectionOptions, SerializedBinding, SerializedNode, SerializedResponsive, ShapeEntry, ShapeIssueCodes, ShowcaseInput, SiteAudit, SiteAuditOptions, SiteAuditSnapshot, SiteBuildRun, SiteBusiness, SiteCheck, SiteFinding, SiteNeedId, SitePageRef, SitePattern, SitePlan, SitePlanIssue, SitePlanSource, SitePlanValidation, SiteTemplate, StockCredit, StockLink, StockPhoto, StockPlacement, StockProviderTerms, StockSlot, StrictSchemaComplexity, StrictSchemaRefusal, TemplatePlanInput, UndoKept, UndoPreview, UndoRecord, WireOptions, WrittenText };
|
|
6581
|
+
export { ACTIVE_BUILD_STATUSES, AI_REFUSAL_NEXT, AI_REFUSAL_STATUS, AI_STEPS, AI_STEP_LABELS, AI_STEP_NEEDS, AI_STEP_PART_TOKENS, AI_STEP_TYPICAL_TOKENS, AiCallStatus, AiCapability, AiFeature, AiGenerationStage, AiKeyMode, AiLimit, AiNextKind, AiProviderId, AiRefusalCode, AiStep, BAR_ITEMS_MAX, BAR_ITEMS_MIN, BASELINE_ROLES, BUILD_STEPS, BaselineRole, BindScope, BuildNoticeCode, BuildStep, CONTENT_ROLE_MEANINGS, CREDITS_PER_USD, CardBindTarget, CheckSeverity, ComposeError, ComposeIssueCode, ContentRole, ContentTypeAction, CreatedKind, DANGLING_LINK_CODES, DEFAULT_PHONE_MENU_BREAKPOINT, DEFAULT_SITE_LOCALE, ENTRANCE_EASING, ENTRANCE_ORIGIN_LOOK, ENTRANCE_TRIGGER, EntrancePart, EntranceSide, FACT_KEYS, FINAL_GENERATION_STAGES, FINDING_SEVERITY, GRAMMAR_TOO_LARGE_MESSAGE, GlobalSlot, HEBREW_PREFIXES, JSON_SCHEMA_BANNED_KEYWORDS, JsonSchemaShapeError, KeptReason, NAV_PART_LABELS, NAV_SHAPE_COPY, NEUTRAL_STYLE_PROPS, NODE_BASELINES, NO_TOKENS, NavPagesSource, NavRole, NavShape, NavShapeError, PAGE_ARCHETYPES, PLAN_EDITS_ANSWER_SHAPES, PLAN_EDITS_PART_SCHEMA_NAMES, PLAN_EDIT_OP_PART, PLAN_EDIT_SHAPE, PLAN_EDIT_WIRE_KEYS, PLAN_PARTS, PLAN_PART_KEYS, PLAN_WIRE_DERIVED, PLAN_WIRE_PART_SCHEMA_NAMES, PLAN_WIRE_SHAPE, PRICING_PATTERN, PUBLISH_BLOCKING_PLACEHOLDERS, PageDataKind, PathIssueCollector, PatternDirective, PatternGlobalPosition, PatternOpens, PatternPlaceholder, PatternRecipe, PhoneMenuBreakpoint, PlaceholderKind, PlanChangeAction, PlanChangeKind, PlanDataSourceMode, PlanDataSourceType, PlanEditIssueCode, PlanEditOp, PlanEditsOutcome, PlanPart, PlanWireEntry, PlanWireShapeError, PlanWordIssueCode, PreflightRefusalCode, PublishRefusalCode, REQUIRED_CONTENT_ROLES, RelativeOp, SAMPLE_IMAGE_REF, SITE_CHECKS, SITE_FACT_LABELS, SITE_HEADER_PATTERN, SITE_LIBRARY, SITE_LOCALES, SITE_NEED_IDS, SITE_PATTERNS, SITE_PLAN_VERSION, SITE_TEMPLATES, SPEC_SITE_ARCHETYPES, SPEC_SITE_COPY_LENGTHS, SPEC_SITE_DENSITY_COPY, SPEC_SITE_FACTS, SPEC_SITE_LIBRARY, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_PATTERNS, SPEC_SITE_PLAN_SHAPE, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES, SPEC_SITE_TEMPLATES, STOCK_FAMILIES, STOCK_PROVIDER_TERMS, STRICT_SCHEMA_BUDGET, STRICT_SCHEMA_LIMITS, STUDIO_TEMPLATE, SiteBuildKind, SiteBuildStatus, SiteCheckId, SiteFindingCode, SiteLocale, SitePlanError, SitePlanIssueCode, SitePlanSourceKind, SiteTemplateError, SiteTemplateId, StepState, StockProviderId, addBarItem, addStockCredits, aiStepParts, applyPlanEdits, applyStockPhotos, archetypeIssues, assertStyleSlots, assertValidSitePlan, auditSite, barIconForRoute, blocksPublish, buildKeys, buildStepFacts, builtHomePageId, composeNavHeader, composeSection, composeSite, contentTypePreviewFields, contentTypeToWire, copyLengthOf, costUsd, createSerializedNode, creditsOfUsd, dataSourceToWire, derivedDataSources, describePlanData, describeShapeKey, entranceAction, entranceInteraction, estimateStepCredits, expandPlanWire, expandWireContentType, expandWireDataSource, expandWireEdit, expandWireField, expandWirePage, expandWireSection, failedBuildStep, fieldToWire, findNavRoot, isActiveBuild, isNonEmptyString, isPlainObject, isRtlLocale, jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, jsonSchemaKeywords, jsonSchemaObjects, jsonShapedObject, jsonText, modelCanDo, moveBarItem, navPages, navShapeBreakpoint, navShapeOf, nextSourceOf, nodeBindPaths, pageToWire, parsePatternPath, parseSampleImageRef, pathAt, patternById, patternHasForm, patternLinkSlots, patternListGap, patternManifest, patternNeedsBinding, patternNodes, patternOptionalParts, patternRoleNeeds, patternScopes, patternSlotBudgets, patternSlots, patternTakesBinding, phoneMenuPages, planEditOps, planEditToWire, planEditWireGaps, planEditWireKeys, planEditWireOps, planEditWireShape, planEditsFromWire, planEditsJsonSchema, planEditsToWire, planEntrances, planFromTemplate, planNavShape, planPartOfPath, planPartToWire, planStockPhotos, planToWire, planWireGaps, planWirePartJsonSchema, planWirePartShape, planWordIssues, publishRefusal, publishVerdict, recipeListBinds, releasedLayoutDefaults, removeBarItem, requiredAcknowledgements, requiredShapeKeys, roleField, routeMatches, routeParams, sampleImageRef, sectionEntrances, sectionPatternIds, sectionToWire, setStyleProps, shapeKeys, showcaseGlobals, showcasePages, showcasePlans, siteNavPages, slotBudget, stockCopyKey, stockCreditLine, stockCreditLines, stockCreditNode, stockCreditNodeId, stockSlots, strictSchemaComplexity, strictSchemaRefusal, typicalStepTokens, validatePlanEdits, validateSitePlan, walkSerialized, wireContentTypeJsonSchema, wireDataSourceJsonSchema, wireFieldJsonSchema, wirePageJsonSchema, wireSectionJsonSchema, withDerivedSources, wordPattern, writtenTexts };
|
|
6582
|
+
export type { AddBarItemResult, AiEntitlementsView, AiMeter, AiModelEntry, AiRefusal, AiTokenCounts, AiTokenPrice, AuditPage, AuditPlaceholder, BarItemsEditResult, BuildNotice, BuildStepError, BuildStepFacts, BuildStepRecord, CmsDataSourcePlan, ComposeContext, ComposeIssue, ComposedDataSource, ComposedItem, ComposedNavHeader, ComposedPage, ComposedPlaceholder, ComposedRoute, ComposedSection, ComposedSite, ContentFieldPlan, ContentRoles, ContentSeedPlan, ContentTypePlan, CreateNodeOptions, CreatedRecord, DataSourcePlan, DynamicKeysAsList, EntranceAction, EntranceActionOptions, EntranceInteraction, EntranceMark, EntranceSpec, EntranceStamp, FactKey, JsonSchema, NavHeaderInput, NavHeaderOptions, NavPage, NavShapeOptions, NavShapeResult, NodeBaseline, PageArchetype, PageArchetypeId, PageArchetypeSlot, PageData, PageDataLine, PagePlan, PageSectionPlan, PageSeoPlan, PathIssue, PatternManifestEntry, PatternNode, PatternPath, PatternRoleNeed, PlainObject, PlanChange, PlanEdit, PlanEditIssue, PlanEditResult, PlanEditsAnswer, PlanEntrancesInput, PlanWireContext, PlanWireParts, PlanWordContext, PlanWordIssue, PreflightRefusal, PublishRefusal, RecipeListBinds, RelativeDataSourcePlan, SectionEntrances, SectionInput, SectionOptions, SerializedBinding, SerializedNode, SerializedResponsive, ShapeEntry, ShapeIssueCodes, ShowcaseInput, SiteAudit, SiteAuditOptions, SiteAuditSnapshot, SiteBuildRun, SiteBusiness, SiteCheck, SiteFinding, SiteNeedId, SitePageRef, SitePattern, SitePlan, SitePlanIssue, SitePlanSource, SitePlanValidation, SiteTemplate, StockCredit, StockLink, StockPhoto, StockPlacement, StockProviderTerms, StockSlot, StrictSchemaComplexity, StrictSchemaRefusal, TemplatePlanInput, UndoKept, UndoPreview, UndoRecord, WireOptions, WrittenText };
|
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a dialog LEAVES (CC1 follow-up — the drawer that vanished). Opening is pure CSS (`@starting-style`
|
|
3
|
+
* in the shared `iox-design.css`); closing needs one beat of script, because `dialog.close()` drops the
|
|
4
|
+
* top layer at once and only some browsers can hold it for a transition (`overlay … allow-discrete`).
|
|
5
|
+
*
|
|
6
|
+
* The ONE exit every renderer runs: mark the element closing (`data-iox-closing` — the shared CSS gives it
|
|
7
|
+
* its closed look: the drawer back off its edge, the scrim clear), wait for the transitions that started,
|
|
8
|
+
* then finish. Reduced motion (or a stub with no animations) finishes now. Framework-free: the engine's
|
|
9
|
+
* every close path (actions, the X, the backdrop, Esc, the close-above watcher) and the builder canvas's
|
|
10
|
+
* stage share it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const DIALOG_CLOSING_ATTRIBUTE = 'data-iox-closing';
|
|
14
|
+
|
|
15
|
+
/** Longest an exit may hold the element before it finishes anyway — a transition that never ends (a
|
|
16
|
+
* value that did not change, a tab in the background) must not leave a dialog half-closed. */
|
|
17
|
+
export const DIALOG_EXIT_TIMEOUT_MS = 700;
|
|
18
|
+
|
|
19
|
+
export interface ExitAnimationLike {
|
|
20
|
+
finished: Promise<unknown>;
|
|
21
|
+
effect?: { getComputedTiming?: () => { endTime?: number | null } } | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExitMotionElement {
|
|
25
|
+
setAttribute(name: string, value: string): void;
|
|
26
|
+
removeAttribute(name: string): void;
|
|
27
|
+
hasAttribute(name: string): boolean;
|
|
28
|
+
getAnimations?: (options?: { subtree?: boolean }) => ExitAnimationLike[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DialogElementLike extends ExitMotionElement {
|
|
32
|
+
open: boolean;
|
|
33
|
+
close(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExitMotionOptions {
|
|
37
|
+
/** False under reduced motion: finish at once (the caller asks `motionAllowed()`). */
|
|
38
|
+
animate: boolean;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True while an exit is running on the element (a second close waits on it; an open cancels it). */
|
|
43
|
+
export function isExiting(el: ExitMotionElement | null | undefined): boolean {
|
|
44
|
+
return !!el?.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stop an exit that is running (the dialog was opened again mid-close): it stays open. */
|
|
48
|
+
export function cancelExit(el: ExitMotionElement | null | undefined): void {
|
|
49
|
+
el?.removeAttribute(DIALOG_CLOSING_ATTRIBUTE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const finite = (a: ExitAnimationLike) => {
|
|
53
|
+
const end = a.effect?.getComputedTiming?.().endTime;
|
|
54
|
+
return end === undefined || end === null || Number.isFinite(end);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run the exit: mark the element, wait for its finite animations (its subtree's too — the drawer's links,
|
|
59
|
+
* the `::backdrop`) or the timeout. Resolves TRUE when the exit completed and the caller should finish
|
|
60
|
+
* (close / remove), FALSE when it was cancelled meanwhile. The mark stays on until the caller finishes.
|
|
61
|
+
*/
|
|
62
|
+
export async function runExitMotion(el: ExitMotionElement, options: ExitMotionOptions): Promise<boolean> {
|
|
63
|
+
el.setAttribute(DIALOG_CLOSING_ATTRIBUTE, '');
|
|
64
|
+
if (!options.animate) return true;
|
|
65
|
+
// Reading the animations flushes style, so the transitions the mark just started are in the list.
|
|
66
|
+
const running = (el.getAnimations?.({ subtree: true }) ?? []).filter(finite);
|
|
67
|
+
if (running.length) {
|
|
68
|
+
const timeout = options.timeoutMs ?? DIALOG_EXIT_TIMEOUT_MS;
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
await Promise.race([
|
|
71
|
+
Promise.all(running.map(a => a.finished.catch(() => undefined))),
|
|
72
|
+
new Promise<void>(resolve => { timer = setTimeout(resolve, timeout); }),
|
|
73
|
+
]);
|
|
74
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
return isExiting(el);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pending = new WeakMap<object, Promise<void>>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Close a live `<dialog>` with its exit motion. A second call while it is closing joins the first; an open
|
|
83
|
+
* in between (`cancelExit`) keeps it open. The dialog's own `close` event still fires once, after the
|
|
84
|
+
* motion — so whatever a runtime does on close (restart the page scroll, sync the openers, return focus)
|
|
85
|
+
* happens when the dialog is really gone.
|
|
86
|
+
*/
|
|
87
|
+
export function closeDialogAnimated(el: DialogElementLike, options: ExitMotionOptions): Promise<void> {
|
|
88
|
+
if (!el.open) return Promise.resolve();
|
|
89
|
+
const running = pending.get(el);
|
|
90
|
+
if (running && isExiting(el)) return running;
|
|
91
|
+
const done = runExitMotion(el, options).then(finish => {
|
|
92
|
+
pending.delete(el);
|
|
93
|
+
if (!finish) return;
|
|
94
|
+
if (el.open) el.close();
|
|
95
|
+
cancelExit(el);
|
|
96
|
+
});
|
|
97
|
+
pending.set(el, done);
|
|
98
|
+
return done;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True when a toggle should CLOSE the dialog: it is open and not already on its way out. */
|
|
102
|
+
export function dialogTogglesClosed(el: Pick<DialogElementLike, 'open' | 'hasAttribute'>): boolean {
|
|
103
|
+
return el.open && !el.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
|
|
104
|
+
}
|
|
@@ -4,9 +4,9 @@ import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-u
|
|
|
4
4
|
import {
|
|
5
5
|
ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
|
|
6
6
|
buildAccordionRecipe, buildCardRecipe, buildCarouselRecipe, buildLightboxRecipe, CardLayout, CardPart, CardRole, CARD_LAYOUTS, CARD_PARTS, CompositeNode,
|
|
7
|
-
CompositeNodeSpec, findByRole, LIGHTBOX_LABEL,
|
|
7
|
+
AccordionAutoBinds, CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
|
|
8
8
|
tabPanelImageOf, tabPanelTextOf, tabsListOf, tabsPanels, tabsPanelsOf, tabsTriggers, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, TABS_LABEL_BIND_PROP,
|
|
9
|
-
TABS_TEXT_BIND_PROP, TABS_WORDS, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
|
|
9
|
+
TABS_TEXT_BIND_PROP, TABS_WORDS, TabsAutoBinds, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
|
|
10
10
|
} from '@vectoriox/iox-ui/composite-core';
|
|
11
11
|
|
|
12
12
|
import { EntranceMark, EntrancePart, planEntrances } from './entrances';
|
|
@@ -14,6 +14,7 @@ import { NODE_BASELINES } from './node-baselines.generated';
|
|
|
14
14
|
import {
|
|
15
15
|
BindScope, CardBindTarget, parsePatternPath, PatternDirective, PatternNode, PatternOpens, PatternPlaceholder, PatternRecipe, SitePattern, patternById,
|
|
16
16
|
} from './patterns';
|
|
17
|
+
import { recipeListBinds } from './recipe-fields';
|
|
17
18
|
import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps, walkSerialized } from './serialized-node';
|
|
18
19
|
import {
|
|
19
20
|
ContentRole, ContentTypePlan, DataSourcePlan, DEFAULT_SITE_LOCALE, FactKey, FACT_KEYS, GlobalSlot, isRtlLocale, PagePlan, PageSectionPlan, PlanDataSourceMode,
|
|
@@ -838,6 +839,15 @@ function composeSegmented(pn: PatternNode, id: string): SerializedNode {
|
|
|
838
839
|
return root;
|
|
839
840
|
}
|
|
840
841
|
|
|
842
|
+
/** The content type a section binding lists, through its data source. */
|
|
843
|
+
function boundContentType(binding: string, ctx: Ctx): ContentTypePlan | undefined {
|
|
844
|
+
const source = ctx.plan.dataSources.find(d => d.alias === binding);
|
|
845
|
+
return source && 'contentType' in source ? ctx.plan.contentTypes.find(t => t.name === source.contentType) : undefined;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** A type's fields as composite-core's pickers read them. */
|
|
849
|
+
const fieldsOf = (type: ContentTypePlan | undefined) => (type?.fields ?? []).map(f => ({ name: f.name, type: f.type, controller: f.controller }));
|
|
850
|
+
|
|
841
851
|
/**
|
|
842
852
|
* An Accordion (CC2) — the composite-core recipe stamped with this composer's factory. Manual (no section
|
|
843
853
|
* binding): three items whose questions and answers are placeholders the studio rewrites (`ph: 'copy'`), never
|
|
@@ -864,26 +874,17 @@ function composeAccordion(pn: PatternNode, id: string, ctx: Ctx): SerializedNode
|
|
|
864
874
|
}
|
|
865
875
|
return root;
|
|
866
876
|
}
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
const picks = pickAccordionAutoBinds((type?.fields ?? []).map(f => ({ name: f.name, type: f.type, controller: f.controller })));
|
|
870
|
-
if (!picks.question || !picks.answer) {
|
|
871
|
-
throw new ComposeError(`${id}: "${binding}" has no short-text field for the question and long-text field for the answer`);
|
|
872
|
-
}
|
|
877
|
+
const { picks, gap } = recipeListBinds<AccordionAutoBinds>(pn.recipe, fieldsOf(boundContentType(binding, ctx)));
|
|
878
|
+
if (gap) throw new ComposeError(`${id}: "${binding}" has no ${gap}`);
|
|
873
879
|
addBinding(root, { prop: 'source', source: binding, path: '' });
|
|
874
880
|
setProp(root, 'limit', ACCORDION_CONTENT_LIMIT);
|
|
875
881
|
const [template] = accordionItems(root as CompositeNode);
|
|
876
882
|
delete template.itemKey;
|
|
877
|
-
addBinding(accordionQuestionOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.question });
|
|
878
|
-
addBinding(accordionAnswerOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.answer });
|
|
883
|
+
addBinding(accordionQuestionOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.question! });
|
|
884
|
+
addBinding(accordionAnswerOf(template) as SerializedNode, { prop: ACCORDION_BIND_PROP, source: binding, path: picks.answer! });
|
|
879
885
|
return root;
|
|
880
886
|
}
|
|
881
887
|
|
|
882
|
-
/** The content type a section binding lists, through its data source. */
|
|
883
|
-
function boundContentType(binding: string, ctx: Ctx): ContentTypePlan | undefined {
|
|
884
|
-
const source = ctx.plan.dataSources.find(d => d.alias === binding);
|
|
885
|
-
return source && 'contentType' in source ? ctx.plan.contentTypes.find(t => t.name === source.contentType) : undefined;
|
|
886
|
-
}
|
|
887
888
|
|
|
888
889
|
const TABS_ROOT_KEY = 'tabs';
|
|
889
890
|
const TABS_HOLDER_KEYS: ReadonlySet<string> = new Set(['list', 'panels']);
|
|
@@ -920,11 +921,8 @@ function composeTabs(pn: PatternNode, id: string, ctx: Ctx): SerializedNode {
|
|
|
920
921
|
}
|
|
921
922
|
return root;
|
|
922
923
|
}
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
if (!picks.label || !picks.text) {
|
|
926
|
-
throw new ComposeError(`${id}: "${binding}" has no short-text field for the tab and long-text field for its panel`);
|
|
927
|
-
}
|
|
924
|
+
const { picks, gap } = recipeListBinds<TabsAutoBinds>(pn.recipe, fieldsOf(boundContentType(binding, ctx)));
|
|
925
|
+
if (gap) throw new ComposeError(`${id}: "${binding}" has no ${gap}`);
|
|
928
926
|
for (const holder of [tabsListOf(tree), tabsPanelsOf(tree)] as SerializedNode[]) {
|
|
929
927
|
addBinding(holder, { prop: 'source', source: binding, path: '' });
|
|
930
928
|
setProp(holder, 'limit', TABS_CONTENT_LIMIT);
|
|
@@ -933,12 +931,12 @@ function composeTabs(pn: PatternNode, id: string, ctx: Ctx): SerializedNode {
|
|
|
933
931
|
const [panel] = tabsPanels(tree);
|
|
934
932
|
delete tab.itemKey;
|
|
935
933
|
delete panel.itemKey;
|
|
936
|
-
addBinding(tabLabelNodeOf(tab) as SerializedNode, { prop: TABS_LABEL_BIND_PROP, source: binding, path: picks.label });
|
|
937
|
-
addBinding(tabPanelTextOf(panel) as SerializedNode, { prop: TABS_TEXT_BIND_PROP, source: binding, path: picks.text });
|
|
934
|
+
addBinding(tabLabelNodeOf(tab) as SerializedNode, { prop: TABS_LABEL_BIND_PROP, source: binding, path: picks.label! });
|
|
935
|
+
addBinding(tabPanelTextOf(panel) as SerializedNode, { prop: TABS_TEXT_BIND_PROP, source: binding, path: picks.text! });
|
|
938
936
|
const image = tabPanelImageOf(panel) as SerializedNode | undefined;
|
|
939
937
|
if (image && picks.image) {
|
|
940
938
|
addBinding(image, { prop: TABS_IMAGE_BIND_PROP, source: binding, path: picks.image });
|
|
941
|
-
addBinding(image, { prop: 'alt', source: binding, path: picks.label });
|
|
939
|
+
addBinding(image, { prop: 'alt', source: binding, path: picks.label! });
|
|
942
940
|
}
|
|
943
941
|
return root;
|
|
944
942
|
}
|
|
@@ -252,8 +252,18 @@ function makeKit(globals: ReadonlyArray<SerializedNode>, rootId: string, newId?:
|
|
|
252
252
|
|
|
253
253
|
/** Point an opener's dialog actions at `dialogId` (a drawer rebuilt under a new id keeps its openers working). */
|
|
254
254
|
function retarget(node: SerializedNode, dialogId: string): SerializedNode {
|
|
255
|
-
for (const ix of node.interactions as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
|
|
256
|
-
for (const a of ix.actions ?? []) if (a.type ===
|
|
255
|
+
for (const ix of (node.interactions ?? []) as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
|
|
256
|
+
for (const a of ix.actions ?? []) if (a.type === NavDialogAction.Open || a.type === NavDialogAction.Toggle) a.target = dialogId;
|
|
257
|
+
}
|
|
258
|
+
return asToggle(node);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The menu button and More are TOGGLES (a second click closes what the first opened): a part saved before that
|
|
262
|
+
* rule opened only — kept through a change of shape, it toggles from then on. */
|
|
263
|
+
function asToggle(node: SerializedNode): SerializedNode {
|
|
264
|
+
for (const ix of (node.interactions ?? []) as Array<{ trigger?: string; actions?: Array<{ type?: string }> }>) {
|
|
265
|
+
if (ix.trigger !== CLICK) continue;
|
|
266
|
+
for (const a of ix.actions ?? []) if (a.type === NavDialogAction.Open) a.type = NavDialogAction.Toggle;
|
|
257
267
|
}
|
|
258
268
|
return node;
|
|
259
269
|
}
|
|
@@ -267,7 +277,14 @@ function part(kit: Kit, suffix: string, type: string, props: Record<string, unkn
|
|
|
267
277
|
return node;
|
|
268
278
|
}
|
|
269
279
|
|
|
270
|
-
|
|
280
|
+
/** The dialog actions a nav part runs on click. */
|
|
281
|
+
enum NavDialogAction {
|
|
282
|
+
Open = 'openDialog',
|
|
283
|
+
Close = 'closeDialog',
|
|
284
|
+
Toggle = 'toggleDialog',
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function clickTo(kit: Kit, suffix: string, type: NavDialogAction, target: string) {
|
|
271
288
|
return { id: kit.id(`${suffix}-click`), trigger: CLICK, actions: [{ type, target, duration: 0, delay: 0, easing: 'ease' }] };
|
|
272
289
|
}
|
|
273
290
|
|
|
@@ -302,11 +319,15 @@ function labelText(kit: Kit, suffix: string, words: string): SerializedNode {
|
|
|
302
319
|
return part(kit, suffix, TEXT, { content: words, tag: 'span' }, 'button-label');
|
|
303
320
|
}
|
|
304
321
|
|
|
322
|
+
/** The menu button and the drawer's Close are ONE size — and the shared CSS pads the drawer like the header
|
|
323
|
+
* (`.iox-role-nav-drawer` re-declares dialog-pad as nav-pad) — so the Close lands where the menu button is and
|
|
324
|
+
* reads as the same button turned into an X. */
|
|
325
|
+
const MENU_BUTTON_LAY = { width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' };
|
|
326
|
+
|
|
305
327
|
function menuButton(kit: Kit, drawerId: string, at: string): SerializedNode {
|
|
306
|
-
const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton,
|
|
307
|
-
{ width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' });
|
|
328
|
+
const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton, { ...MENU_BUTTON_LAY });
|
|
308
329
|
btn.children = [iconNode(kit, 'menu-icon', MENU_ICON)];
|
|
309
|
-
btn.interactions = [clickTo(kit, 'menu',
|
|
330
|
+
btn.interactions = [clickTo(kit, 'menu', NavDialogAction.Toggle, drawerId)];
|
|
310
331
|
phoneOnly(btn, at, 'inline-flex');
|
|
311
332
|
return btn;
|
|
312
333
|
}
|
|
@@ -322,10 +343,9 @@ function drawer(kit: Kit, root: SerializedNode, pages: NavPage[], at: string): S
|
|
|
322
343
|
const brandWords = textOf(find([root], NavRole.Brand) ?? root) || NAV_PART_LABELS[NavRole.Root];
|
|
323
344
|
const head = part(kit, 'drawer-head', CONTAINER, {}, 'box', NavRole.DrawerHead,
|
|
324
345
|
{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px' });
|
|
325
|
-
const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose,
|
|
326
|
-
{ width: '44px', height: '44px', alignItems: 'center', justifyContent: 'center' });
|
|
346
|
+
const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose, { ...MENU_BUTTON_LAY });
|
|
327
347
|
close.children = [iconNode(kit, 'drawer-close-icon', CLOSE_ICON)];
|
|
328
|
-
close.interactions = [clickTo(kit, 'drawer-close',
|
|
348
|
+
close.interactions = [clickTo(kit, 'drawer-close', NavDialogAction.Close, dr.id)];
|
|
329
349
|
head.children = [part(kit, 'drawer-brand', HEADING, { content: brandWords, level: 'h3' }, 'h3'), close];
|
|
330
350
|
const list = part(kit, 'drawer-links', CONTAINER, {}, 'box', NavRole.DrawerLinks,
|
|
331
351
|
{ display: 'flex', flexDirection: 'column', gap: '4px' });
|
|
@@ -364,7 +384,7 @@ const BAR_ITEM_LAY = {
|
|
|
364
384
|
function moreItem(kit: Kit, drawerId: string): SerializedNode {
|
|
365
385
|
const more = part(kit, 'bar-more', BUTTON, { tag: 'button' }, 'bottom-bar-item', NavRole.More, BAR_ITEM_LAY);
|
|
366
386
|
more.children = [iconNode(kit, 'bar-more-icon', MORE_ICON), labelText(kit, 'bar-more-label', NAV_SHAPE_COPY.more)];
|
|
367
|
-
more.interactions = [clickTo(kit, 'bar-more',
|
|
387
|
+
more.interactions = [clickTo(kit, 'bar-more', NavDialogAction.Toggle, drawerId)];
|
|
368
388
|
return more;
|
|
369
389
|
}
|
|
370
390
|
|
|
@@ -472,7 +492,7 @@ export function planNavShape(globals: ReadonlyArray<SerializedNode>, shape: NavS
|
|
|
472
492
|
|
|
473
493
|
// The menu button: Drawer only (Q4: with a bar, the phone header keeps only the brand).
|
|
474
494
|
const existingMenu = removeRole(root, NavRole.MenuButton);
|
|
475
|
-
const menu = wantMenuButton ? (existingMenu
|
|
495
|
+
const menu = wantMenuButton ? (existingMenu ? asToggle(existingMenu) : menuButton(kit, dr!.id, at)) : null;
|
|
476
496
|
|
|
477
497
|
for (const p of phoneHiddenParts(root)) { moveDisplayOverride(p, at); hideAt(p, at); }
|
|
478
498
|
centerBrandAt(root, at, wantBar);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BindScope, patternById, patternScopes } from './patterns';
|
|
2
|
+
import { DataSourcePlan, PlanDataSourceMode, PlanDataSourceType, RelativeOp } from './site-plan.model';
|
|
3
|
+
import { isNonEmptyString, isPlainObject, PlainObject } from './shape-check';
|
|
4
|
+
import { nextSourceOf } from './validate-site-plan';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a plan reads but no model is asked to write (vectoriox/iox-cms#49): the data sources that follow from the pages
|
|
8
|
+
* alone. A per-item page whose sections read the NEXT item (`project.next`) needs a `$siblings.next` source of its route
|
|
9
|
+
* source — a fact of the pages, while data sources are the plan's STRUCTURE, asked first and fixed once accepted. Asking
|
|
10
|
+
* the structure for it made the pages' answer unfixable by construction (a re-ask of the pages cannot add a source), so
|
|
11
|
+
* it is derived, the same way for the plan wire's expansion (`expandPlanWire`) and for a sentence's page edits
|
|
12
|
+
* (`applyPlanEdits`). Pure and idempotent: a plan that already holds the source gains nothing; a source is derived only
|
|
13
|
+
* where it is valid by construction (its subject a single cms source of a type of the plan), so it never adds an issue
|
|
14
|
+
* at a structure path. Its siblings are walked within an unlimited list of the same type — the structure's first, else a
|
|
15
|
+
* derived one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const NEXT_SUFFIX = 'Next';
|
|
19
|
+
const LIST_SUFFIX = 'All';
|
|
20
|
+
|
|
21
|
+
interface SourceView { type: string; mode?: string; contentType?: string; limit?: unknown; of?: string; op?: string; }
|
|
22
|
+
|
|
23
|
+
/** The sources by alias, as the validator reads them (the first of an alias wins). */
|
|
24
|
+
function sourceViews(list: ReadonlyArray<unknown>): Map<string, SourceView> {
|
|
25
|
+
const views = new Map<string, SourceView>();
|
|
26
|
+
for (const raw of list) {
|
|
27
|
+
if (isPlainObject(raw) && isNonEmptyString(raw['alias']) && !views.has(raw['alias'])) views.set(raw['alias'], raw as unknown as SourceView);
|
|
28
|
+
}
|
|
29
|
+
return views;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const readsNext = (page: PlainObject) => Array.isArray(page['sections']) && page['sections'].some(s => {
|
|
33
|
+
const pattern = isPlainObject(s) && typeof s['pattern'] === 'string' ? patternById(s['pattern']) : undefined;
|
|
34
|
+
return !!pattern && patternScopes(pattern).has(BindScope.Next);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** The alias itself, else the first of `<alias>2`, `<alias>3`… no source has. */
|
|
38
|
+
function freeAlias(base: string, taken: ReadonlyMap<string, unknown>): string {
|
|
39
|
+
let alias = base;
|
|
40
|
+
for (let n = 2; taken.has(alias); n++) alias = `${base}${n}`;
|
|
41
|
+
return alias;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The unlimited list of `contentType` the plan holds (its first), or null. */
|
|
45
|
+
function fullListOf(views: ReadonlyMap<string, SourceView>, contentType: string): string | null {
|
|
46
|
+
for (const [alias, s] of views) {
|
|
47
|
+
if (s.type === PlanDataSourceType.Cms && s.mode === PlanDataSourceMode.Query && s.contentType === contentType && s.limit === undefined) return alias;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The data sources `plan` lacks and its pages need, in page order (appended after the plan's own). Tolerates any
|
|
54
|
+
* value: what is not a well-formed plan gains nothing, for the validator to judge as written.
|
|
55
|
+
*/
|
|
56
|
+
export function derivedDataSources(plan: unknown): DataSourcePlan[] {
|
|
57
|
+
if (!isPlainObject(plan) || !Array.isArray(plan['dataSources']) || !Array.isArray(plan['pages']) || !Array.isArray(plan['contentTypes'])) return [];
|
|
58
|
+
const types = new Set(plan['contentTypes'].map(t => (isPlainObject(t) ? t['name'] : undefined)).filter(isNonEmptyString));
|
|
59
|
+
const views = sourceViews(plan['dataSources']);
|
|
60
|
+
const derived: DataSourcePlan[] = [];
|
|
61
|
+
const add = (source: DataSourcePlan) => { derived.push(source); views.set(source.alias, source); };
|
|
62
|
+
for (const page of plan['pages']) {
|
|
63
|
+
if (!isPlainObject(page) || !isNonEmptyString(page['routeSource']) || !readsNext(page)) continue;
|
|
64
|
+
const subject = page['routeSource'];
|
|
65
|
+
const route = views.get(subject);
|
|
66
|
+
if (!route || route.type !== PlanDataSourceType.Cms || route.mode !== PlanDataSourceMode.Single) continue;
|
|
67
|
+
if (!isNonEmptyString(route.contentType) || !types.has(route.contentType) || nextSourceOf(views, subject)) continue;
|
|
68
|
+
let within = fullListOf(views, route.contentType);
|
|
69
|
+
if (!within) {
|
|
70
|
+
within = freeAlias(`${subject}${LIST_SUFFIX}`, views);
|
|
71
|
+
add({ alias: within, type: PlanDataSourceType.Cms, mode: PlanDataSourceMode.Query, contentType: route.contentType });
|
|
72
|
+
}
|
|
73
|
+
add({ alias: freeAlias(`${subject}${NEXT_SUFFIX}`, views), type: PlanDataSourceType.Relative, of: subject, op: RelativeOp.SiblingsNext, within });
|
|
74
|
+
}
|
|
75
|
+
return derived;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `plan` with the data sources its pages need appended (`derivedDataSources`); the same object when it lacks none. */
|
|
79
|
+
export function withDerivedSources<T>(plan: T): T {
|
|
80
|
+
const derived = derivedDataSources(plan);
|
|
81
|
+
if (!derived.length) return plan;
|
|
82
|
+
const own = plan as unknown as PlainObject;
|
|
83
|
+
return { ...own, dataSources: [...(own['dataSources'] as unknown[]), ...derived] } as T;
|
|
84
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
jsonAnyOf, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
|
|
3
3
|
} from './json-schema';
|
|
4
4
|
import { patternById, patternOptionalParts, patternSlots, patternTakesBinding } from './patterns';
|
|
5
|
+
import { derivedDataSources } from './plan-derived';
|
|
5
6
|
import {
|
|
6
7
|
contentTypeToWire, dataSourceToWire, expandWireContentType, expandWireDataSource, expandWireField, expandWirePage, expandWireSection, fieldToWire,
|
|
7
8
|
pageToWire, PLAN_PARTS, PlanPart, PlanWireShapeError, sectionPatternIds, sectionToWire, wireContentTypeJsonSchema, wireDataSourceJsonSchema,
|
|
@@ -367,8 +368,9 @@ const APPLY: Readonly<Record<`${PlanEditOp}`, Apply>> = {
|
|
|
367
368
|
|
|
368
369
|
/**
|
|
369
370
|
* Apply a set of edits to a plan — all of them, or none (any issue refuses the set and returns the plan unchanged). The
|
|
370
|
-
* sources only removed pages and sections read are dropped with them; sources nothing read before are kept
|
|
371
|
-
*
|
|
371
|
+
* sources only removed pages and sections read are dropped with them; sources nothing read before are kept; the sources
|
|
372
|
+
* the pages now need and no model writes are derived (`derivedDataSources` — a page edit adding `project.next`, as the
|
|
373
|
+
* plan wire's expansion does, vectoriox/iox-cms#49). The result still has to pass `validateSitePlan`.
|
|
372
374
|
*/
|
|
373
375
|
export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>): PlanEditResult {
|
|
374
376
|
const structure = validatePlanEdits(edits);
|
|
@@ -384,8 +386,11 @@ export function applyPlanEdits(plan: SitePlan, edits: ReadonlyArray<PlanEdit>):
|
|
|
384
386
|
const added = new Set(changed.filter(ch => ch.kind === PlanChangeKind.DataSource).map(ch => ch.name));
|
|
385
387
|
const dataSources = draft.dataSources.filter(s => after.has(s.alias) || !before.has(s.alias) || added.has(s.alias));
|
|
386
388
|
for (const s of draft.dataSources) if (!dataSources.includes(s)) changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Removed, name: s.alias });
|
|
389
|
+
const edited = { ...plan, pages: draft.pages, contentTypes: draft.contentTypes, dataSources };
|
|
390
|
+
const derived = derivedDataSources(edited);
|
|
391
|
+
for (const s of derived) changed.push({ kind: PlanChangeKind.DataSource, action: PlanChangeAction.Added, name: s.alias });
|
|
387
392
|
|
|
388
|
-
return { plan: { ...
|
|
393
|
+
return { plan: derived.length ? { ...edited, dataSources: [...dataSources, ...derived] } : edited, changed, issues: [] };
|
|
389
394
|
}
|
|
390
395
|
|
|
391
396
|
// ── The format a model writes edits in ─────────────────────────────────────────────────────────────────
|
|
@@ -3,6 +3,7 @@ import { FORM_PRESETS } from '@vectoriox/iox-ui/forms-core';
|
|
|
3
3
|
|
|
4
4
|
import { describeShapeKey, jsonAnyOf, jsonBoolean, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText } from './json-schema';
|
|
5
5
|
import { patternLinkSlots, patternOptionalParts, patternSlots, PatternGlobalPosition, SITE_PATTERNS } from './patterns';
|
|
6
|
+
import { withDerivedSources } from './plan-derived';
|
|
6
7
|
import {
|
|
7
8
|
ContentFieldPlan, ContentTypePlan, DataSourcePlan, FactKey, GlobalSlot, PAGE_ARCHETYPES, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
|
|
8
9
|
RelativeOp, SITE_NEED_IDS, SITE_PLAN_VERSION, SitePlan, SitePlanSource, SiteLocale,
|
|
@@ -30,6 +31,10 @@ import { isPlainObject, PlainObject, ShapeEntry, shapeKeys } from './shape-check
|
|
|
30
31
|
* - **What the model does not decide is not on the wire** (`PLAN_WIRE_DERIVED`): the look (the owner's pick), the run,
|
|
31
32
|
* the language (the reading's), the facts (the owner's own, verbatim), the version, the home page (the page at `/`).
|
|
32
33
|
* So the format is the same for every studio and every run — one compiled grammar, cached by the provider.
|
|
34
|
+
* - **What follows from the pages is derived, never asked** (`plan-derived.ts`, vectoriox/iox-cms#49): a per-item page
|
|
35
|
+
* reading the next item gets its `$siblings.next` source — the structure, asked first and then fixed, cannot know the
|
|
36
|
+
* pages need it, and the pages cannot add a source. `planToWire` writes every source the plan holds, a derived one
|
|
37
|
+
* too (it is the plan's: a change's brief shows it, and a plan → wire → plan round trip stays exact).
|
|
33
38
|
*
|
|
34
39
|
* ONE source: every wire key names the plan keys it carries (`refs`) and is described in the plan table's own words, and
|
|
35
40
|
* every plan key is either carried or derived (`planWireGaps()`); a key the plan shape gains breaks the builder until it
|
|
@@ -91,7 +96,7 @@ const WIRE_KEYS: Readonly<Record<`${PlanWireEntry}`, Readonly<Record<string, Wir
|
|
|
91
96
|
plan: {
|
|
92
97
|
business: inPart(PlanPart.Structure, key('plan.business')),
|
|
93
98
|
contentTypes: inPart(PlanPart.Structure, key('plan.contentTypes')),
|
|
94
|
-
dataSources: inPart(PlanPart.Structure, { refs: ['plan.dataSources'], text:
|
|
99
|
+
dataSources: inPart(PlanPart.Structure, { refs: ['plan.dataSources'], text: "dataSource[] — merged by alias into the org library; a per-item page's $siblings.next source (what a section reading the next item needs) is added for you" }),
|
|
95
100
|
pages: inPart(PlanPart.Pages, key('plan.pages')),
|
|
96
101
|
globals: inPart(PlanPart.Pages, key('plan.globals')),
|
|
97
102
|
},
|
|
@@ -465,7 +470,7 @@ export type PlanWireParts = Partial<Record<PlanPart, unknown>>;
|
|
|
465
470
|
* A model's plan (its parts, on the wire) + what the generation decides (the look, the run, the language, the owner's
|
|
466
471
|
* facts) → the Site Plan. Pure. Translates, never repairs: whatever an answer got wrong is still wrong, for
|
|
467
472
|
* `validateSitePlan` — a part that is not an object is the result itself, a part not written yet leaves its keys out
|
|
468
|
-
* (the validator then names them, at their own paths).
|
|
473
|
+
* (the validator then names them, at their own paths). What follows from the pages alone is added (`withDerivedSources`).
|
|
469
474
|
*/
|
|
470
475
|
export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext): SitePlan | unknown {
|
|
471
476
|
const written = PLAN_PARTS.filter(part => parts[part] !== undefined).map(part => parts[part]);
|
|
@@ -473,7 +478,7 @@ export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext):
|
|
|
473
478
|
if (odd !== undefined) return odd;
|
|
474
479
|
const wire: PlainObject = Object.assign({}, ...(written as PlainObject[]));
|
|
475
480
|
const { business, contentTypes, dataSources, pages, ...rest } = wire;
|
|
476
|
-
return {
|
|
481
|
+
return withDerivedSources({
|
|
477
482
|
...rest,
|
|
478
483
|
version: SITE_PLAN_VERSION,
|
|
479
484
|
source: { ...context.source },
|
|
@@ -483,7 +488,7 @@ export function expandPlanWire(parts: PlanWireParts, context: PlanWireContext):
|
|
|
483
488
|
contentTypes: each(contentTypes, expandWireContentType),
|
|
484
489
|
dataSources: each(dataSources, expandWireDataSource),
|
|
485
490
|
pages: each(pages, expandWirePage),
|
|
486
|
-
};
|
|
491
|
+
});
|
|
487
492
|
}
|
|
488
493
|
|
|
489
494
|
// ── The plan → the wire ────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -27,6 +27,8 @@ export * from './plan-data';
|
|
|
27
27
|
export * from './showcase';
|
|
28
28
|
export * from './site-build-run';
|
|
29
29
|
export * from './ai-run';
|
|
30
|
+
export * from './recipe-fields';
|
|
31
|
+
export * from './plan-derived';
|
|
30
32
|
export * from './plan-wire';
|
|
31
33
|
export * from './strict-schema';
|
|
32
34
|
export * from './plan-edits';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ContentFieldLike, pickAccordionAutoBinds, pickTabsAutoBinds } from '@vectoriox/iox-ui/composite-core';
|
|
2
|
+
|
|
3
|
+
import { PatternDirective, patternNodes, PatternRecipe, SitePattern } from './patterns';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The fields a recipe repeating a section's list binds (Accordion, Tabs — R3's From content): composite-core's pickers,
|
|
7
|
+
* the builder switch's rule, decide them. ONE rule for the composer (which binds them) and the gate (which refuses,
|
|
8
|
+
* at the section's `binding`, a list whose type cannot fill the recipe — vectoriox/iox-cms#49: it used to surface only
|
|
9
|
+
* as the composer's error at the plan's root, where the pages part could not tell which section to change).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface ListRecipe {
|
|
13
|
+
picks(fields: ReadonlyArray<ContentFieldLike>): Readonly<Record<string, string | null>>;
|
|
14
|
+
/** The picks the recipe cannot go without. */
|
|
15
|
+
required: ReadonlyArray<string>;
|
|
16
|
+
/** What the listed type must have, in words. */
|
|
17
|
+
needs: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LIST_RECIPES: Readonly<Partial<Record<`${PatternRecipe}`, ListRecipe>>> = {
|
|
21
|
+
[PatternRecipe.Accordion]: { picks: f => ({ ...pickAccordionAutoBinds(f) }), required: ['question', 'answer'], needs: 'short-text field for the question and long-text field for the answer' },
|
|
22
|
+
[PatternRecipe.Tabs]: { picks: f => ({ ...pickTabsAutoBinds(f) }), required: ['label', 'text'], needs: 'short-text field for the tab and long-text field for its panel' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface RecipeListBinds<T> {
|
|
26
|
+
picks: T;
|
|
27
|
+
/** What the fields lack (the recipe's `needs`), or null when they fill it. */
|
|
28
|
+
gap: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The fields `recipe` binds from a listed type's `fields`, and what they lack. A recipe that lists nothing lacks nothing. */
|
|
32
|
+
export function recipeListBinds<T = Readonly<Record<string, string | null>>>(recipe: string | undefined, fields: ReadonlyArray<ContentFieldLike>): RecipeListBinds<T> {
|
|
33
|
+
const spec = recipe ? LIST_RECIPES[recipe as `${PatternRecipe}`] : undefined;
|
|
34
|
+
if (!spec) return { picks: {} as T, gap: null };
|
|
35
|
+
const picks = spec.picks(fields);
|
|
36
|
+
return { picks: picks as unknown as T, gap: spec.required.every(key => picks[key]) ? null : spec.needs };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const listsBinding = (src: string | undefined) => src === PatternDirective.Binding || src === PatternDirective.OptionalBinding;
|
|
40
|
+
|
|
41
|
+
/** What a listed type with `fields` lacks for the pattern's recipes that repeat the section's binding — the first gap, or null. */
|
|
42
|
+
export function patternListGap(pattern: SitePattern, fields: ReadonlyArray<ContentFieldLike>): string | null {
|
|
43
|
+
for (const node of patternNodes(pattern)) {
|
|
44
|
+
if (!listsBinding(node.src)) continue;
|
|
45
|
+
const { gap } = recipeListBinds(node.recipe, fields);
|
|
46
|
+
if (gap) return gap;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|