@vectoriox/iox-ui 5.57.0 → 5.58.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.
Files changed (30) hide show
  1. package/composite-core/index.d.ts +3 -3
  2. package/design-core/index.d.ts +40 -2
  3. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +6 -10
  4. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  5. package/fesm2022/vectoriox-iox-ui-design-core.mjs +46 -1
  6. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +4041 -739
  8. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  9. package/fesm2022/vectoriox-iox-ui-harness-core.mjs +3 -3
  10. package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
  11. package/fesm2022/vectoriox-iox-ui.mjs +27 -1
  12. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  13. package/generation-core/index.d.ts +3737 -1100
  14. package/harness-core/index.d.ts +6 -6
  15. package/index.d.ts +22 -1
  16. package/package.json +1 -1
  17. package/src/composite-core/accordion-runtime.ts +2 -1
  18. package/src/composite-core/lightbox.ts +5 -6
  19. package/src/design-core/entrance-timing.ts +56 -0
  20. package/src/design-core/public-api.ts +1 -0
  21. package/src/generation-core/compose-site.ts +213 -42
  22. package/src/generation-core/entrances.ts +82 -13
  23. package/src/generation-core/patterns.ts +127 -15
  24. package/src/generation-core/public-api.ts +3 -2
  25. package/src/generation-core/showcase.ts +81 -0
  26. package/src/generation-core/site-plan-schema.ts +21 -8
  27. package/src/generation-core/site-plan.model.ts +75 -4
  28. package/src/generation-core/site-spec.generated.ts +4019 -1174
  29. package/src/generation-core/validate-site-plan.ts +129 -21
  30. package/src/harness-core/harness-spec.generated.ts +3 -3
@@ -1,10 +1,14 @@
1
1
  import { flavorPackById, parseHexColor, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
2
+ import { formPresetById } from '@vectoriox/iox-ui/forms-core';
3
+ import { SEO_DESCRIPTION_MAX, SEO_TITLE_MAX } from '@vectoriox/iox-ui/seo-core';
2
4
 
3
5
  import {
4
- altSourceScopes, BindScope, patternById, patternNeedsBinding, patternOptionalParts, patternTakesBinding, patternScopes, patternSlots, PatternGlobalPosition,
6
+ BindScope, patternById, patternHasForm, patternLinkSlots, patternNeedsBinding, patternOptionalParts, patternRoleNeeds, patternTakesBinding, patternScopes,
7
+ patternSlots, PatternGlobalPosition, slotBudget,
5
8
  } from './patterns';
6
9
  import {
7
- FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlanSourceKind, SITE_PLAN_VERSION,
10
+ FACT_KEYS, GlobalSlot, PAGE_ARCHETYPES, PlanDataSourceMode, PlanDataSourceType, RelativeOp, REQUIRED_CONTENT_ROLES, SitePlan, SitePlanSourceKind,
11
+ SITE_LOCALES, SITE_PLAN_VERSION,
8
12
  } from './site-plan.model';
9
13
  import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
10
14
  import {
@@ -43,9 +47,26 @@ export enum SitePlanIssueCode {
43
47
  UnknownOptionalPart = 'unknown-optional-part',
44
48
  Binding = 'binding',
45
49
  MissingScope = 'missing-scope',
46
- /** A pattern binds an item's image, and the item's content type names no `previewTextField` to be
47
- * its alt text. */
48
- MissingPreviewField = 'missing-preview-field',
50
+ /** A pattern reads a role (`@title`) the bound item's content type does not name (L0-3), or a type names no title. */
51
+ MissingRole = 'missing-role',
52
+ /** A role the contract does not know, or one played by a field the type does not have. */
53
+ UnknownRole = 'unknown-role',
54
+ /** A page type that does not exist; or (`archetypeIssues`, the plan stage's own check) sections that do not fit it. */
55
+ Archetype = 'archetype',
56
+ /** A slot's words are longer than its budget under the look's density (L0-2, M1-6c): rewrite, never cut. */
57
+ SlotTooLong = 'slot-too-long',
58
+ /** A section re-points a slot that does not link, or to a route the plan has no page for. */
59
+ InvalidLink = 'invalid-link',
60
+ /** A section's form preset is not a forms-core preset, or the pattern holds no form. */
61
+ InvalidForm = 'invalid-form',
62
+ /** A section's entrance is not a How animated level. */
63
+ InvalidEnter = 'invalid-enter',
64
+ /** The plan's language is not a site locale. */
65
+ InvalidLocale = 'invalid-locale',
66
+ /** A page's SEO words are longer than a search result shows (seo-core limits). */
67
+ InvalidSeo = 'invalid-seo',
68
+ /** A page key that is a global slot's name (`header`, `footer`): its sections' ids would be the global's. */
69
+ ReservedPageKey = 'reserved-page-key',
49
70
  }
50
71
 
51
72
  export type SitePlanIssue = PathIssue<SitePlanIssueCode>;
@@ -75,6 +96,9 @@ const BUSINESS_KEYS = shapeKeys(SHAPE['business']);
75
96
  const DESIGN_REQUIRED = requiredShapeKeys(SHAPE['design']);
76
97
  const DESIGN_KEYS = shapeKeys(SHAPE['design']);
77
98
  const CONTENT_TYPE_KEYS = shapeKeys(SHAPE['contentType']);
99
+ const ROLE_KEYS = shapeKeys(SHAPE['roles']);
100
+ const PAGE_SEO_KEYS = shapeKeys(SHAPE['pageSeo']);
101
+ const ARRAY_TYPE = 'array';
78
102
  const FIELD_KEYS = shapeKeys(SHAPE['field']);
79
103
  const SEED_FIXED_KEYS = shapeKeys(SHAPE['seed']);
80
104
  const CMS_SOURCE_KEYS = shapeKeys(SHAPE['cmsDataSource']);
@@ -129,6 +153,12 @@ function validateBusiness(c: Collector, business: unknown): void {
129
153
  }
130
154
  }
131
155
 
156
+ function validateLocale(c: Collector, locale: unknown): void {
157
+ if (locale !== undefined && (typeof locale !== 'string' || !has(SITE_LOCALES, locale))) {
158
+ c.add(SitePlanIssueCode.InvalidLocale, 'locale', `must be one of ${Object.keys(SITE_LOCALES).join(', ')}`);
159
+ }
160
+ }
161
+
132
162
  function validateDesign(c: Collector, design: unknown): void {
133
163
  if (!isObject(design)) return c.add(SitePlanIssueCode.Shape, 'design', 'must be an object');
134
164
  c.keys(design, DESIGN_KEYS, 'design');
@@ -155,7 +185,7 @@ function validateDesign(c: Collector, design: unknown): void {
155
185
  if (design['fonts'] !== undefined && !isObject(design['fonts'])) bad('fonts', 'must be an object');
156
186
  }
157
187
 
158
- interface ContentTypeInfo { fields: Set<string>; previewTextField?: string; }
188
+ interface ContentTypeInfo { fields: Set<string>; roles: Record<string, string>; }
159
189
 
160
190
  function validateContentTypes(c: Collector, list: unknown[]): Map<string, ContentTypeInfo> {
161
191
  const types = new Map<string, ContentTypeInfo>();
@@ -177,12 +207,8 @@ function validateContentTypes(c: Collector, list: unknown[]): Map<string, Conten
177
207
  });
178
208
  c.unique(names, j => `${path}.fields[${j}].name`, 'field');
179
209
  const fieldSet = new Set(names.filter((n): n is string => !!n));
180
- const preview: Record<string, string | null> = {};
181
- for (const key of ['previewTextField', 'previewImageField']) {
182
- const v = c.string(raw, key, path, false);
183
- if (v && !fieldSet.has(v)) c.add(SitePlanIssueCode.UnknownField, `${path}.${key}`, `"${v}" is not a field of ${name}`);
184
- preview[key] = v && fieldSet.has(v) ? v : null;
185
- }
210
+ const fieldTypes = new Map(fields.filter(isObject).map(f => [f['name'], f['type']]));
211
+ const roles = validateRoles(c, raw['roles'], `${path}.roles`, name, fieldSet, fieldTypes);
186
212
  const seed = raw['seed'];
187
213
  if (seed !== undefined) {
188
214
  if (!isObject(seed)) c.add(SitePlanIssueCode.Shape, `${path}.seed`, 'must be an object');
@@ -195,11 +221,25 @@ function validateContentTypes(c: Collector, list: unknown[]): Map<string, Conten
195
221
  }
196
222
  }
197
223
  if (name && types.has(name)) c.add(SitePlanIssueCode.Duplicate, `${path}.name`, `content type "${name}" is used twice`);
198
- else if (name) types.set(name, { fields: fieldSet, previewTextField: preview['previewTextField'] ?? undefined });
224
+ else if (name) types.set(name, { fields: fieldSet, roles });
199
225
  });
200
226
  return types;
201
227
  }
202
228
 
229
+ /** A content type's roles (L0-3): known roles only, each played by a field of the type; `title` always; `gallery` a list. */
230
+ function validateRoles(c: Collector, roles: unknown, path: string, typeName: string | null, fields: Set<string>, fieldTypes: Map<unknown, unknown>): Record<string, string> {
231
+ if (!isObject(roles)) { c.add(SitePlanIssueCode.Shape, path, 'must be an object (which field plays each role)'); return {}; }
232
+ const out: Record<string, string> = {};
233
+ for (const [role, field] of Object.entries(roles)) {
234
+ if (!ROLE_KEYS.includes(role)) { c.add(SitePlanIssueCode.UnknownRole, `${path}.${role}`, `"${role}" is not a role (${ROLE_KEYS.join(', ')})`); continue; }
235
+ if (typeof field !== 'string' || !fields.has(field)) { c.add(SitePlanIssueCode.UnknownRole, `${path}.${role}`, `"${String(field)}" is not a field of ${typeName}`); continue; }
236
+ if (role === 'gallery' && fieldTypes.get(field) !== ARRAY_TYPE) { c.add(SitePlanIssueCode.UnknownRole, `${path}.${role}`, `"${field}" is not a list field — a gallery is a list of pictures`); continue; }
237
+ out[role] = field;
238
+ }
239
+ for (const role of REQUIRED_CONTENT_ROLES) if (!out[role] && !has(roles, role)) c.add(SitePlanIssueCode.MissingRole, `${path}.${role}`, `every content type names its ${role} field`);
240
+ return out;
241
+ }
242
+
203
243
  interface SourceInfo { type: string; mode?: string; contentType?: string; routeParam?: string; of?: string; op?: string; }
204
244
 
205
245
  function validateDataSources(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>): Map<string, SourceInfo> {
@@ -255,13 +295,17 @@ function validateDataSources(c: Collector, list: unknown[], types: Map<string, C
255
295
  return sources;
256
296
  }
257
297
 
258
- function validatePages(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>, sources: Map<string, SourceInfo>): void {
298
+ function validatePages(c: Collector, list: unknown[], types: Map<string, ContentTypeInfo>, sources: Map<string, SourceInfo>, density: string | null): void {
259
299
  const homes: number[] = [];
300
+ const routes = new Set(list.map(p => (isObject(p) && typeof p['route'] === 'string' ? p['route'] : null)).filter((r): r is string => !!r));
260
301
  list.forEach((raw, i) => {
261
302
  const path = `pages[${i}]`;
262
303
  if (!isObject(raw)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
263
304
  c.keys(raw, PAGE_KEYS, path);
264
- c.string(raw, 'key', path);
305
+ const key = c.string(raw, 'key', path);
306
+ if (key && (Object.values(GlobalSlot) as string[]).includes(key)) {
307
+ c.add(SitePlanIssueCode.ReservedPageKey, `${path}.key`, `"${key}" names the site's ${key} — a page's sections would share its ids`);
308
+ }
265
309
  c.string(raw, 'name', path);
266
310
  for (const key of ['isHome', 'required', 'inNav']) c.boolean(raw, key, path);
267
311
  const route = c.string(raw, 'route', path);
@@ -288,6 +332,10 @@ function validatePages(c: Collector, list: unknown[], types: Map<string, Content
288
332
  }
289
333
  }
290
334
 
335
+ const archetype = c.string(raw, 'archetype', path, false);
336
+ if (archetype && !has(PAGE_ARCHETYPES, archetype)) c.add(SitePlanIssueCode.Archetype, `${path}.archetype`, `"${archetype}" is not a page type (${Object.keys(PAGE_ARCHETYPES).join(', ')})`);
337
+ validatePageSeo(c, raw['seo'], `${path}.seo`);
338
+
291
339
  c.array(raw, 'sections', path).forEach((s, j) => {
292
340
  const sp = `${path}.sections[${j}]`;
293
341
  if (!isObject(s)) return c.add(SitePlanIssueCode.Shape, sp, 'must be an object');
@@ -305,9 +353,29 @@ function validatePages(c: Collector, list: unknown[], types: Map<string, Content
305
353
  for (const [slot, value] of Object.entries(slots)) {
306
354
  if (!declared.includes(slot)) c.add(SitePlanIssueCode.UnknownSlot, `${sp}.slots.${slot}`, `"${patternId}" has no slot "${slot}" (${declared.join(', ') || 'none'})`);
307
355
  else if (typeof value !== 'string') c.add(SitePlanIssueCode.Shape, `${sp}.slots.${slot}`, 'must be a string');
356
+ else {
357
+ const budget = slotBudget(pattern, slot, density);
358
+ if (budget !== null && value.length > budget) c.add(SitePlanIssueCode.SlotTooLong, `${sp}.slots.${slot}`, `${value.length} characters — the slot holds ${budget} at the ${density ?? 'regular'} density`);
359
+ }
360
+ }
361
+ }
362
+ }
363
+ const links = s['links'];
364
+ if (links !== undefined) {
365
+ if (!isObject(links)) c.add(SitePlanIssueCode.Shape, `${sp}.links`, 'must be an object');
366
+ else {
367
+ const linkSlots = patternLinkSlots(pattern);
368
+ for (const [slot, route] of Object.entries(links)) {
369
+ if (!linkSlots.includes(slot)) c.add(SitePlanIssueCode.InvalidLink, `${sp}.links.${slot}`, `"${patternId}" has no linked slot "${slot}" (${linkSlots.join(', ') || 'none'})`);
370
+ else if (typeof route !== 'string' || !routes.has(route) || routeParams(route).length) c.add(SitePlanIssueCode.InvalidLink, `${sp}.links.${slot}`, `"${String(route)}" is not a page route of the plan`);
308
371
  }
309
372
  }
310
373
  }
374
+ const form = c.string(s, 'form', sp, false);
375
+ if (form && !patternHasForm(pattern)) c.add(SitePlanIssueCode.InvalidForm, `${sp}.form`, `"${patternId}" holds no form`);
376
+ else if (form && !formPresetById(form)) c.add(SitePlanIssueCode.InvalidForm, `${sp}.form`, `"${form}" is not a form preset`);
377
+ const enter = c.string(s, 'enter', sp, false);
378
+ if (enter && !has(SPEC_AXES.animations, enter)) c.add(SitePlanIssueCode.InvalidEnter, `${sp}.enter`, `must be one of ${Object.keys(SPEC_AXES.animations).join(', ')}`);
311
379
  const without = s['without'];
312
380
  if (without !== undefined) {
313
381
  const parts = patternOptionalParts(pattern);
@@ -337,13 +405,15 @@ function validatePages(c: Collector, list: unknown[], types: Map<string, Content
337
405
  c.add(SitePlanIssueCode.MissingScope, `${sp}.pattern`, `"${patternId}" reads the next item — the plan needs a ${RelativeOp.SiblingsNext} source of ${routeSourceAlias}`);
338
406
  }
339
407
 
340
- // A bound image's alt text is its item's previewTextField (W1.1): the item's type must name one.
341
- for (const scope of altSourceScopes(pattern)) {
342
- const alias = scope === BindScope.Item ? binding : routeSourceOk ? routeSourceAlias : null;
408
+ // L0-3: the roles a pattern reads must be roles of the item's type — the section's list (`item`), the route
409
+ // item (`route`) or its next sibling (`next`, the same type). An optional one (`@image?`) is only left out.
410
+ for (const need of patternRoleNeeds(pattern)) {
411
+ if (need.optional) continue;
412
+ const alias = need.scope === BindScope.Item ? binding : routeSourceOk ? routeSourceAlias : null;
343
413
  const typeName = alias ? sources.get(alias)?.contentType : undefined;
344
414
  const type = typeName ? types.get(typeName) : undefined;
345
- if (type && !type.previewTextField) {
346
- c.add(SitePlanIssueCode.MissingPreviewField, `${sp}.pattern`, `"${patternId}" shows ${typeName} images — ${typeName} needs a previewTextField for their alt text`);
415
+ if (type && !type.roles[need.role]) {
416
+ c.add(SitePlanIssueCode.MissingRole, `${sp}.pattern`, `"${patternId}" shows each ${typeName} item's ${need.role} — ${typeName} names no ${need.role} field (roles.${need.role})`);
347
417
  }
348
418
  }
349
419
  });
@@ -353,6 +423,18 @@ function validatePages(c: Collector, list: unknown[], types: Map<string, Content
353
423
  if (homes.length !== 1) c.add(SitePlanIssueCode.Home, 'pages', `exactly one page is the home page (found ${homes.length})`);
354
424
  }
355
425
 
426
+
427
+ function validatePageSeo(c: Collector, seo: unknown, path: string): void {
428
+ if (seo === undefined) return;
429
+ if (!isObject(seo)) return c.add(SitePlanIssueCode.Shape, path, 'must be an object');
430
+ c.keys(seo, PAGE_SEO_KEYS, path);
431
+ const limits: Readonly<Record<string, number>> = { title: SEO_TITLE_MAX, description: SEO_DESCRIPTION_MAX };
432
+ for (const key of PAGE_SEO_KEYS) {
433
+ const value = c.string(seo, key, path, false);
434
+ if (value && value.length > limits[key]) c.add(SitePlanIssueCode.InvalidSeo, `${path}.${key}`, `${value.length} characters — a search result shows ${limits[key]}`);
435
+ }
436
+ }
437
+
356
438
  /** The plan's `$siblings.next` source of `routeSource` — what a pattern's `next.*` reads. */
357
439
  export function nextSourceOf(sources: Map<string, { type: string; of?: string; op?: string }>, routeSource: string): string | null {
358
440
  for (const [alias, s] of sources) if (s.type === PlanDataSourceType.Relative && s.of === routeSource && s.op === RelativeOp.SiblingsNext) return alias;
@@ -381,10 +463,12 @@ export function validateSitePlan(plan: unknown): SitePlanValidation {
381
463
  if (plan['version'] !== SITE_PLAN_VERSION) c.add(SitePlanIssueCode.Version, 'version', `must be "${SITE_PLAN_VERSION}"`);
382
464
  validateSource(c, plan['source']);
383
465
  validateBusiness(c, plan['business']);
466
+ validateLocale(c, plan['locale']);
384
467
  validateDesign(c, plan['design']);
385
468
  const types = validateContentTypes(c, c.array(plan, 'contentTypes', ''));
386
469
  const sources = validateDataSources(c, c.array(plan, 'dataSources', ''), types);
387
- validatePages(c, c.array(plan, 'pages', ''), types, sources);
470
+ const design = isObject(plan['design']) ? plan['design'] : {};
471
+ validatePages(c, c.array(plan, 'pages', ''), types, sources, typeof design['density'] === 'string' ? design['density'] : null);
388
472
  validateGlobals(c, plan['globals']);
389
473
  return { valid: c.issues.length === 0, issues: c.issues };
390
474
  }
@@ -394,3 +478,27 @@ export function assertValidSitePlan(plan: unknown): void {
394
478
  const result = validateSitePlan(plan);
395
479
  if (!result.valid) throw new SitePlanError(result.issues);
396
480
  }
481
+
482
+ /**
483
+ * A plan's pages against their page types (L0-2): every required slot filled, every section of a family the type takes.
484
+ * NOT part of `validateSitePlan` — a page type is what the model READS to give a page its shape, and the plan stage
485
+ * holds the model's plan to it; the owner may then make any page their own (merge About into Contact, drop the
486
+ * closing band), which the gate must not refuse. Pure; issues carry the same paths.
487
+ */
488
+ export function archetypeIssues(plan: SitePlan): SitePlanIssue[] {
489
+ const c = new Collector();
490
+ plan.pages.forEach((page, i) => {
491
+ const type = page.archetype ? PAGE_ARCHETYPES[page.archetype] : undefined;
492
+ if (!type) return;
493
+ const families = page.sections.map(s => patternById(s.pattern)?.family ?? '');
494
+ for (const slot of type.slots) {
495
+ if (slot.required && !families.some(f => slot.families.includes(f))) {
496
+ c.add(SitePlanIssueCode.Archetype, `pages[${i}].sections`, `a ${page.archetype} page needs a ${slot.label} section (${slot.families.join(' / ')})`);
497
+ }
498
+ }
499
+ families.forEach((family, j) => {
500
+ if (!type.slots.some(slot => slot.families.includes(family))) c.add(SitePlanIssueCode.Archetype, `pages[${i}].sections[${j}]`, `a ${family} section does not belong on a ${page.archetype} page`);
501
+ });
502
+ });
503
+ return c.issues;
504
+ }
@@ -138,7 +138,7 @@ export const SPEC_STRUCTURES = {
138
138
  "About",
139
139
  "Contact"
140
140
  ],
141
- "shipped": false
141
+ "shipped": true
142
142
  },
143
143
  "place": {
144
144
  "label": "Place",
@@ -146,11 +146,11 @@ export const SPEC_STRUCTURES = {
146
146
  "pages": [
147
147
  "Home",
148
148
  "Menu",
149
- "Visit (hours + map)",
149
+ "Visit (hours and address)",
150
150
  "About",
151
151
  "Contact"
152
152
  ],
153
- "shipped": false
153
+ "shipped": true
154
154
  }
155
155
  } as const;
156
156