@appsemble/lang-sdk 0.38.0 → 0.38.2-test.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/gridUtils.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { type GridLayoutDefinition, type PageDefinition, type PageLayoutDefinition, type ResponsiveGridLayoutDefinition } from './types/index.js';
2
+ /**
3
+ * The template area a page grid layout uses to place the breadcrumb trail.
4
+ */
5
+ export declare const breadcrumbsGridArea = "breadcrumbs";
6
+ /**
7
+ * The devices a responsive grid layout is defined for, from the smallest breakpoint up.
8
+ */
9
+ export declare const gridDeviceOrder: readonly ["mobile", "tablet", "desktop"];
10
+ /**
11
+ * One of the devices a responsive grid layout is defined for.
12
+ */
13
+ export type GridDeviceName = (typeof gridDeviceOrder)[number];
14
+ /**
15
+ * The grid a device falls back to when neither it nor a smaller device defines one.
16
+ */
17
+ export declare const defaultGridLayout: Readonly<GridLayoutDefinition>;
18
+ /**
19
+ * Collect the template areas a responsive grid layout defines.
20
+ *
21
+ * @param layoutDefinition The responsive grid layout to read.
22
+ * @returns Every area named by the template of any device layout, without the `.` placeholder.
23
+ */
24
+ export declare function getGridTemplateAreas(layoutDefinition: ResponsiveGridLayoutDefinition | undefined): Set<string>;
25
+ /**
26
+ * Collect the grid layouts of a page, in definition order.
27
+ *
28
+ * A page type that splits its content over sub pages defines a grid per sub page, so a tab, a step
29
+ * or a loop sub page each carry their own. A basic page has a single grid, and a container page has
30
+ * none of its own.
31
+ *
32
+ * @param page The page to read the grid layouts of.
33
+ * @returns The layout of every sub page the page renders, which is undefined where none is defined.
34
+ */
35
+ export declare function getPageGridLayouts(page: PageDefinition): (PageLayoutDefinition | undefined)[];
36
+ /**
37
+ * Collect the template areas each device renders.
38
+ *
39
+ * A device that defines no template of its own inherits the one of the next smaller device, the way
40
+ * the app applies the grid of a page, down to a fallback of the single `main` area. So the areas a
41
+ * device renders are not the areas its own template names.
42
+ *
43
+ * @param layout The responsive grid layout to read.
44
+ * @returns The areas rendered by each device, keyed by device name.
45
+ */
46
+ export declare function getCascadedGridTemplateAreas(layout: ResponsiveGridLayoutDefinition | undefined): Record<GridDeviceName, Set<string>>;
47
+ /**
48
+ * Check whether a grid layout places the breadcrumb trail.
49
+ *
50
+ * Every device has to render the area, since a device that does not would place the trail against
51
+ * implicit grid lines rather than inside the layout.
52
+ *
53
+ * @param layout The responsive grid layout to check.
54
+ * @returns Whether every device renders the breadcrumbs template area.
55
+ */
56
+ export declare function hasBreadcrumbsGridArea(layout: PageLayoutDefinition | undefined): boolean;
57
+ /**
58
+ * Check whether a page renders the breadcrumb trail from a grid layout.
59
+ *
60
+ * @param page The page to check the grid layouts of.
61
+ * @returns Whether any grid layout of the page places the breadcrumb trail.
62
+ */
63
+ export declare function pageHasBreadcrumbsGridArea(page: PageDefinition): boolean;
package/gridUtils.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The template area a page grid layout uses to place the breadcrumb trail.
3
+ */
4
+ export const breadcrumbsGridArea = 'breadcrumbs';
5
+ /**
6
+ * The devices a responsive grid layout is defined for, from the smallest breakpoint up.
7
+ */
8
+ export const gridDeviceOrder = ['mobile', 'tablet', 'desktop'];
9
+ /**
10
+ * The grid a device falls back to when neither it nor a smaller device defines one.
11
+ */
12
+ export const defaultGridLayout = {
13
+ columns: 1,
14
+ template: ['main'],
15
+ };
16
+ function getTemplateAreas(template) {
17
+ const areas = new Set();
18
+ for (const row of template) {
19
+ for (const area of row.split(' ')) {
20
+ if (area && area !== '.') {
21
+ areas.add(area);
22
+ }
23
+ }
24
+ }
25
+ return areas;
26
+ }
27
+ /**
28
+ * Collect the template areas a responsive grid layout defines.
29
+ *
30
+ * @param layoutDefinition The responsive grid layout to read.
31
+ * @returns Every area named by the template of any device layout, without the `.` placeholder.
32
+ */
33
+ export function getGridTemplateAreas(layoutDefinition) {
34
+ const areas = new Set();
35
+ if (!layoutDefinition) {
36
+ return areas;
37
+ }
38
+ for (const deviceDefinition of Object.values(layoutDefinition)) {
39
+ for (const area of getTemplateAreas(deviceDefinition?.layout?.template ?? [])) {
40
+ areas.add(area);
41
+ }
42
+ }
43
+ return areas;
44
+ }
45
+ /**
46
+ * Collect the grid layouts of a page, in definition order.
47
+ *
48
+ * A page type that splits its content over sub pages defines a grid per sub page, so a tab, a step
49
+ * or a loop sub page each carry their own. A basic page has a single grid, and a container page has
50
+ * none of its own.
51
+ *
52
+ * @param page The page to read the grid layouts of.
53
+ * @returns The layout of every sub page the page renders, which is undefined where none is defined.
54
+ */
55
+ export function getPageGridLayouts(page) {
56
+ switch (page.type) {
57
+ case 'container':
58
+ return [];
59
+ case 'flow':
60
+ return page.steps.map((step) => step.layout);
61
+ case 'loop':
62
+ return [page.start, page.foreach, page.end].flatMap((subPage) => subPage ? [subPage.layout] : []);
63
+ case 'tabs':
64
+ return page.tabs
65
+ ? page.tabs.map((tab) => tab.layout)
66
+ : page.definition
67
+ ? [page.definition.foreach.layout]
68
+ : [];
69
+ default:
70
+ return [page.layout];
71
+ }
72
+ }
73
+ /**
74
+ * Collect the template areas each device renders.
75
+ *
76
+ * A device that defines no template of its own inherits the one of the next smaller device, the way
77
+ * the app applies the grid of a page, down to a fallback of the single `main` area. So the areas a
78
+ * device renders are not the areas its own template names.
79
+ *
80
+ * @param layout The responsive grid layout to read.
81
+ * @returns The areas rendered by each device, keyed by device name.
82
+ */
83
+ export function getCascadedGridTemplateAreas(layout) {
84
+ let { template } = defaultGridLayout;
85
+ return Object.fromEntries(gridDeviceOrder.map((device) => {
86
+ template = layout?.[device]?.layout?.template ?? template;
87
+ return [device, getTemplateAreas(template)];
88
+ }));
89
+ }
90
+ /**
91
+ * Check whether a grid layout places the breadcrumb trail.
92
+ *
93
+ * Every device has to render the area, since a device that does not would place the trail against
94
+ * implicit grid lines rather than inside the layout.
95
+ *
96
+ * @param layout The responsive grid layout to check.
97
+ * @returns Whether every device renders the breadcrumbs template area.
98
+ */
99
+ export function hasBreadcrumbsGridArea(layout) {
100
+ if (!layout) {
101
+ return false;
102
+ }
103
+ return Object.values(getCascadedGridTemplateAreas(layout)).every((areas) => areas.has(breadcrumbsGridArea));
104
+ }
105
+ /**
106
+ * Check whether a page renders the breadcrumb trail from a grid layout.
107
+ *
108
+ * @param page The page to check the grid layouts of.
109
+ * @returns Whether any grid layout of the page places the breadcrumb trail.
110
+ */
111
+ export function pageHasBreadcrumbsGridArea(page) {
112
+ return getPageGridLayouts(page).some(hasBreadcrumbsGridArea);
113
+ }
114
+ //# sourceMappingURL=gridUtils.js.map
package/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './authorization.js';
6
6
  export * from './blockUtils.js';
7
7
  export * from './validation.js';
8
8
  export * from './findPageByName.js';
9
+ export * from './gridUtils.js';
9
10
  export * from './pageUtils.js';
10
11
  export * from './iterApp.js';
11
12
  export * from './serverActions.js';
package/index.js CHANGED
@@ -6,6 +6,7 @@ export * from './authorization.js';
6
6
  export * from './blockUtils.js';
7
7
  export * from './validation.js';
8
8
  export * from './findPageByName.js';
9
+ export * from './gridUtils.js';
9
10
  export * from './pageUtils.js';
10
11
  export * from './iterApp.js';
11
12
  export * from './serverActions.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/lang-sdk",
3
- "version": "0.38.0",
3
+ "version": "0.38.2-test.0",
4
4
  "description": "Language SDK for Appsemble",
5
5
  "keywords": [
6
6
  "app",
package/pageUtils.d.ts CHANGED
@@ -5,4 +5,13 @@ export declare function getPageMessageId(pageName: string): string;
5
5
  export declare function getPagePathSegment(page: string | NamedPage): string;
6
6
  export declare function getPageDisplayName(page: NamedPage, getAppMessage: MessageGetter): string;
7
7
  export declare function findPageById(pages: PageDefinition[], normalizedPageId: string, appMessageIds: string[], getAppMessage: MessageGetter): PageDefinition | null;
8
+ /**
9
+ * Collect the pages a page sits under, by following `parent` upwards.
10
+ *
11
+ * @param pages The pages of the app.
12
+ * @param page The page to collect the ancestors of.
13
+ * @param appMemberRoles The roles held by the app member, used to resolve role-scoped parents.
14
+ * @returns The ancestors, root first, excluding the page itself.
15
+ */
16
+ export declare function getPageAncestors(pages: PageDefinition[], page: PageDefinition, appMemberRoles?: string[]): PageDefinition[];
8
17
  export {};
package/pageUtils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { findPageByName } from './findPageByName.js';
1
2
  import { normalize } from './normalize.js';
2
3
  export function getPageMessageId(pageName) {
3
4
  return `pages.${normalize(pageName)}`;
@@ -35,4 +36,44 @@ export function findPageById(pages, normalizedPageId, appMessageIds, getAppMessa
35
36
  }
36
37
  return null;
37
38
  }
39
+ /**
40
+ * Resolve which parent page applies to a member.
41
+ *
42
+ * @param parent The `parent` of a page, in any of its accepted forms.
43
+ * @param appMemberRoles The roles held by the app member.
44
+ * @returns The name of the applicable parent page, if any.
45
+ */
46
+ function resolveParentName(parent, appMemberRoles) {
47
+ for (const entry of [].concat(parent ?? [])) {
48
+ if (typeof entry === 'string') {
49
+ return entry;
50
+ }
51
+ if (!entry.roles || entry.roles.some((role) => appMemberRoles.includes(role))) {
52
+ return entry.page;
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * Collect the pages a page sits under, by following `parent` upwards.
58
+ *
59
+ * @param pages The pages of the app.
60
+ * @param page The page to collect the ancestors of.
61
+ * @param appMemberRoles The roles held by the app member, used to resolve role-scoped parents.
62
+ * @returns The ancestors, root first, excluding the page itself.
63
+ */
64
+ export function getPageAncestors(pages, page, appMemberRoles = []) {
65
+ const ancestors = [];
66
+ const visited = new Set([page.name]);
67
+ let current = page;
68
+ for (let parentName = resolveParentName(current.parent, appMemberRoles); parentName; parentName = resolveParentName(current.parent, appMemberRoles)) {
69
+ const parent = findPageByName(pages, parentName);
70
+ if (!parent || visited.has(parent.name)) {
71
+ break;
72
+ }
73
+ visited.add(parent.name);
74
+ ancestors.unshift(parent);
75
+ current = parent;
76
+ }
77
+ return ancestors;
78
+ }
38
79
  //# sourceMappingURL=pageUtils.js.map
@@ -91,6 +91,15 @@ rendered as side navigation.
91
91
  type: 'string',
92
92
  enum: ['appName', 'pageName'],
93
93
  },
94
+ breadcrumbs: {
95
+ type: 'boolean',
96
+ default: false,
97
+ description: `Whether to display a breadcrumb trail below the title bar.
98
+
99
+ The trail is built from the \`parent\` of each page. A grid layout that names a \`breadcrumbs\`
100
+ template area renders the trail in that area instead.
101
+ `,
102
+ },
94
103
  hideTitleBar: {
95
104
  description: 'Whether to hide the title bar',
96
105
  type: 'boolean',
@@ -53,6 +53,25 @@ navigation. Set to \`hidden\` to display no navigational menus at all.
53
53
  This will be displayed in the navigation menu.
54
54
  `,
55
55
  },
56
+ parent: {
57
+ description: `The page this page sits under in the breadcrumb hierarchy.
58
+
59
+ A page name applies to every app member. An entry carrying \`roles\` applies only to members holding
60
+ one of those roles. A list is resolved in order, so the first matching entry wins and an entry
61
+ without \`roles\` acts as the fallback.
62
+
63
+ A page that declares \`parameters\` cannot be used as a parent, because its URL cannot be resolved
64
+ from another page.
65
+ `,
66
+ anyOf: [
67
+ { $ref: '#/components/schemas/PageParentDefinition' },
68
+ {
69
+ type: 'array',
70
+ minItems: 1,
71
+ items: { $ref: '#/components/schemas/PageParentDefinition' },
72
+ },
73
+ ],
74
+ },
56
75
  parameters: {
57
76
  type: 'array',
58
77
  description: `Page parameters can be used for linking to a page that should display a single resource.
@@ -91,7 +91,10 @@ The exact meaning of the parameters depends on the block type.
91
91
  events: { $ref: '#/components/schemas/EventsDefinition' },
92
92
  gridArea: {
93
93
  type: 'string',
94
- description: 'Grid area defined in the page layout definition',
94
+ description: `Grid area defined in the page layout definition.
95
+
96
+ The \`breadcrumbs\` area is reserved for the breadcrumb trail of the app.
97
+ `,
95
98
  },
96
99
  },
97
100
  };
@@ -4,6 +4,11 @@ export const PageLayoutDefinition = extendJSONSchema(ResponsiveGridLayoutDefinit
4
4
  type: 'object',
5
5
  additionalProperties: false,
6
6
  minProperties: 1,
7
- description: 'Responsive grid layout for a page',
7
+ description: `Responsive grid layout for a page.
8
+
9
+ The \`breadcrumbs\` template area is reserved for the breadcrumb trail of the app. It requires
10
+ \`layout.breadcrumbs\`, has to be part of the grid every breakpoint renders, and has to be defined by
11
+ every sub page of a page or by none of them.
12
+ `,
8
13
  });
9
14
  //# sourceMappingURL=PageLayoutDefinition.js.map
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const PageParentDefinition: OpenAPIV3.SchemaObject;
@@ -0,0 +1,35 @@
1
+ export const PageParentDefinition = {
2
+ description: `A candidate parent page, optionally scoped to a set of roles.
3
+
4
+ A plain string names the parent page. An object scopes that parent to the roles that may use it.
5
+ `,
6
+ anyOf: [
7
+ {
8
+ type: 'string',
9
+ maxLength: 50,
10
+ description: 'The name of the parent page.',
11
+ },
12
+ {
13
+ type: 'object',
14
+ additionalProperties: false,
15
+ required: ['page'],
16
+ properties: {
17
+ page: {
18
+ type: 'string',
19
+ maxLength: 50,
20
+ description: 'The name of the parent page.',
21
+ },
22
+ roles: {
23
+ type: 'array',
24
+ minItems: 1,
25
+ items: { type: 'string' },
26
+ description: `The roles this parent applies to.
27
+
28
+ When omitted, the parent applies to every app member.
29
+ `,
30
+ },
31
+ },
32
+ },
33
+ ],
34
+ };
35
+ //# sourceMappingURL=PageParentDefinition.js.map
@@ -131,3 +131,4 @@ export * from './ResponsiveGridLayoutDefinition.js';
131
131
  export * from './NavbarDeviceGridLayoutDefinition.js';
132
132
  export * from './NavbarLayoutDefinition.js';
133
133
  export * from './PageLayoutDefinition.js';
134
+ export * from './PageParentDefinition.js';
package/schemas/index.js CHANGED
@@ -131,4 +131,5 @@ export * from './ResponsiveGridLayoutDefinition.js';
131
131
  export * from './NavbarDeviceGridLayoutDefinition.js';
132
132
  export * from './NavbarLayoutDefinition.js';
133
133
  export * from './PageLayoutDefinition.js';
134
+ export * from './PageParentDefinition.js';
134
135
  //# sourceMappingURL=index.js.map
package/types/core.d.ts CHANGED
@@ -108,6 +108,10 @@ export interface AppDefinition {
108
108
  *
109
109
  */
110
110
  titleBarText?: 'appName' | 'pageName';
111
+ /**
112
+ * Whether to display a breadcrumb trail below the title bar.
113
+ */
114
+ breadcrumbs?: boolean;
111
115
  /**
112
116
  * Whether to hide the title bar from all pages in the app.
113
117
  */
@@ -199,6 +203,13 @@ export interface AppDefinition {
199
203
  */
200
204
  registry?: string;
201
205
  }
206
+ /**
207
+ * A candidate parent page, optionally scoped to a set of roles.
208
+ */
209
+ export type PageParentDefinition = string | {
210
+ page: string;
211
+ roles?: string[];
212
+ };
202
213
  /**
203
214
  * This describes what a page will look like in the app.
204
215
  */
@@ -233,6 +244,14 @@ export interface BasePageDefinition {
233
244
  * top navigation is rendered as side navigation.
234
245
  */
235
246
  navigation?: Navigation | 'profileDropdown';
247
+ /**
248
+ * The page this page sits under in the breadcrumb hierarchy.
249
+ *
250
+ * A page name applies to every member. An entry carrying `roles` applies only to members holding
251
+ * one of them. A list is resolved in order, so the first matching entry wins and an entry without
252
+ * `roles` acts as the fallback.
253
+ */
254
+ parent?: PageParentDefinition | PageParentDefinition[];
236
255
  /**
237
256
  * A list of roles that may view the page.
238
257
  */
package/validation.js CHANGED
@@ -6,6 +6,7 @@ import { BlockParamInstanceValidator } from './BasicValidator.js';
6
6
  import { getAppBlocks, normalizeBlockName } from './blockUtils.js';
7
7
  import { partialNormalized } from './constants/index.js';
8
8
  import { findPageByName } from './findPageByName.js';
9
+ import { breadcrumbsGridArea, getCascadedGridTemplateAreas, getGridTemplateAreas, gridDeviceOrder, } from './gridUtils.js';
9
10
  import { iterAction, iterApp } from './iterApp.js';
10
11
  import { has } from './miscellaneous.js';
11
12
  import { normalize } from './normalize.js';
@@ -109,6 +110,70 @@ function validateUniquePageNames(definition, report) {
109
110
  }
110
111
  checkPages(definition.pages);
111
112
  }
113
+ /**
114
+ * Iterate over the parent entries of every page, including the pages nested in a container.
115
+ *
116
+ * `iterApp` gives a nested page the path of the container it sits in, which is too coarse to point
117
+ * at the `parent` of one page, so the pages are walked here instead.
118
+ *
119
+ * @param pages The pages to walk.
120
+ * @param onParent Called with each parent entry, the path it lives at, and the page declaring it.
121
+ * @param prefix The path the pages live at.
122
+ */
123
+ function iterPageParents(pages, onParent, prefix = ['pages']) {
124
+ for (const [index, page] of pages.entries()) {
125
+ const pagePath = [...prefix, index];
126
+ for (const [entryIndex, entry] of []
127
+ .concat(page.parent ?? [])
128
+ .entries()) {
129
+ onParent(entry, Array.isArray(page.parent) ? [...pagePath, 'parent', entryIndex] : [...pagePath, 'parent'], page);
130
+ }
131
+ if (page.type === 'container') {
132
+ iterPageParents(page.pages, onParent, [...pagePath, 'pages']);
133
+ }
134
+ }
135
+ }
136
+ function validatePageParents(definition, report) {
137
+ if (!definition.pages) {
138
+ return;
139
+ }
140
+ // Every declared edge counts, whatever roles it is scoped to: roles overlap, so a cycle reachable
141
+ // under any role assignment is an authoring mistake.
142
+ function referencesItself(pageName, parentName) {
143
+ const visited = new Set();
144
+ const queue = [parentName];
145
+ while (queue.length) {
146
+ const name = queue.shift();
147
+ if (name === pageName) {
148
+ return true;
149
+ }
150
+ if (visited.has(name)) {
151
+ continue;
152
+ }
153
+ visited.add(name);
154
+ const parent = findPageByName(definition.pages, name);
155
+ for (const entry of [].concat(parent?.parent ?? [])) {
156
+ queue.push(typeof entry === 'string' ? entry : entry.page);
157
+ }
158
+ }
159
+ return false;
160
+ }
161
+ iterPageParents(definition.pages, (entry, path, page) => {
162
+ const parentName = typeof entry === 'string' ? entry : entry.page;
163
+ const parent = findPageByName(definition.pages, parentName);
164
+ if (!parent) {
165
+ report(parentName, 'refers to a page that doesn’t exist', path);
166
+ return;
167
+ }
168
+ if (referencesItself(page.name, parentName)) {
169
+ report(parentName, 'cyclically references itself', path);
170
+ return;
171
+ }
172
+ if (parent.parameters?.length) {
173
+ report(parentName, 'refers to a page with parameters, which cannot be used as a parent', path);
174
+ }
175
+ });
176
+ }
112
177
  function validateMembersSchema(definition, report) {
113
178
  if (!definition.members?.properties) {
114
179
  return;
@@ -368,24 +433,6 @@ function validateController(definition, controllerImplementations, report) {
368
433
  },
369
434
  });
370
435
  }
371
- function getTemplateAreas(layoutDefinition) {
372
- const areas = new Set();
373
- if (!layoutDefinition) {
374
- return areas;
375
- }
376
- for (const deviceDefinition of Object.values(layoutDefinition)) {
377
- if (deviceDefinition?.layout?.template) {
378
- for (const row of deviceDefinition.layout.template) {
379
- for (const area of row.split(' ')) {
380
- if (area && area !== '.') {
381
- areas.add(area);
382
- }
383
- }
384
- }
385
- }
386
- }
387
- return areas;
388
- }
389
436
  function validateTemplateAreasAreRectangular(template, report, path) {
390
437
  const grid = template.map((row) => row.split(' '));
391
438
  function isAreaRectangular(areaName) {
@@ -463,16 +510,56 @@ function validateResponsiveGridLayoutDefinition(layoutDefinition, report, path)
463
510
  }
464
511
  }
465
512
  function validateBlockGridAreas(blocks, layoutDefinition, report, path) {
466
- const templateAreas = getTemplateAreas(layoutDefinition);
513
+ const templateAreas = getGridTemplateAreas(layoutDefinition);
467
514
  for (const [idx, block] of blocks.entries()) {
468
- if (block.gridArea && !templateAreas.has(block.gridArea)) {
515
+ if (block.gridArea === breadcrumbsGridArea) {
516
+ report(block.gridArea, 'is reserved for the breadcrumb trail', [...path, idx, 'gridArea']);
517
+ }
518
+ else if (layoutDefinition && block.gridArea && !templateAreas.has(block.gridArea)) {
469
519
  report(block.gridArea, `does not match any area defined in the layout template. Available areas: ${[...templateAreas].join(', ') || 'none'}`, [...path, idx, 'gridArea']);
470
520
  }
471
521
  }
472
522
  }
473
- function validateSubPageLayout(layout, blocks, report, path) {
523
+ function validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled) {
524
+ if (!layout || !getGridTemplateAreas(layout).has(breadcrumbsGridArea)) {
525
+ return;
526
+ }
527
+ if (!breadcrumbsEnabled) {
528
+ for (const [deviceName, deviceDefinition] of Object.entries(layout)) {
529
+ const template = deviceDefinition?.layout?.template;
530
+ if (template?.some((row) => row.split(' ').includes(breadcrumbsGridArea))) {
531
+ report(template, `the '${breadcrumbsGridArea}' grid area requires layout.breadcrumbs to be enabled`, [...path, deviceName, 'layout', 'template']);
532
+ }
533
+ }
534
+ return;
535
+ }
536
+ // A device inherits the template of the next smaller one, so an area defined only for a larger
537
+ // device leaves the trail without a slot to sit in below that breakpoint.
538
+ const cascaded = getCascadedGridTemplateAreas(layout);
539
+ const missing = gridDeviceOrder.filter((device) => !cascaded[device].has(breadcrumbsGridArea));
540
+ if (missing.length) {
541
+ report(layout, `the '${breadcrumbsGridArea}' grid area is missing from the grid rendered on ${missing.join(', ')}`, path);
542
+ }
543
+ }
544
+ function validateConsistentBreadcrumbsGridArea(subPages, report) {
545
+ // The trail belongs to the page, so it may not appear on one sub page and be missing from the
546
+ // next. Defining the area consistently also lets the app runtime tell from the page definition
547
+ // alone whether a page renders the trail from its grid or below the title bar.
548
+ const declaresArea = ([subPage]) => getGridTemplateAreas(subPage.layout).has(breadcrumbsGridArea);
549
+ const declaring = subPages.filter(declaresArea);
550
+ if (!declaring.length || declaring.length === subPages.length) {
551
+ return;
552
+ }
553
+ for (const entry of subPages) {
554
+ if (!declaresArea(entry)) {
555
+ report(entry[0], `the '${breadcrumbsGridArea}' grid area must be defined by every sub page of this page or by none`, entry[1]);
556
+ }
557
+ }
558
+ }
559
+ function validateSubPageLayout(layout, blocks, report, path, breadcrumbsEnabled) {
474
560
  validateResponsiveGridLayoutDefinition(layout, report, path);
475
- if (layout && blocks) {
561
+ validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled);
562
+ if (blocks) {
476
563
  validateBlockGridAreas(blocks, layout, report, [...path, 'blocks']);
477
564
  }
478
565
  }
@@ -532,51 +619,54 @@ function validateGridLayout(definition, report) {
532
619
  }
533
620
  }
534
621
  }
622
+ const breadcrumbsEnabled = definition.layout?.breadcrumbs === true;
535
623
  iterApp(definition, {
536
624
  onPage(page, path) {
537
625
  // Basic page
538
626
  if (page.type === 'page' || page.type === undefined) {
539
627
  const basicPage = page;
540
- validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path);
628
+ validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path, breadcrumbsEnabled);
541
629
  return;
542
630
  }
543
631
  // Tabs page
544
632
  if (page.type === 'tabs') {
545
633
  const tabsPage = page;
546
- if (tabsPage.tabs) {
547
- for (const [tabId, tab] of tabsPage.tabs.entries()) {
548
- validateSubPageLayout(tab.layout, tab.blocks, report, [...path, 'tabs', tabId]);
549
- }
550
- }
551
- else if (tabsPage.definition?.foreach) {
552
- validateSubPageLayout(tabsPage.definition.foreach.layout, tabsPage.definition.foreach.blocks, report, [...path, 'definition', 'foreach']);
553
- }
634
+ const tabs = tabsPage.tabs
635
+ ? tabsPage.tabs.map((tab, tabId) => [tab, [...path, 'tabs', tabId]])
636
+ : tabsPage.definition?.foreach
637
+ ? [[tabsPage.definition.foreach, [...path, 'definition', 'foreach']]]
638
+ : [];
639
+ for (const [tab, tabPath] of tabs) {
640
+ validateSubPageLayout(tab.layout, tab.blocks, report, tabPath, breadcrumbsEnabled);
641
+ }
642
+ validateConsistentBreadcrumbsGridArea(tabs, report);
554
643
  return;
555
644
  }
556
645
  // Flow page
557
646
  if (page.type === 'flow') {
558
647
  const flowPage = page;
559
- for (const [stepId, step] of flowPage.steps.entries()) {
560
- validateSubPageLayout(step.layout, step.blocks, report, [...path, 'steps', stepId]);
648
+ const steps = flowPage.steps.map((step, stepId) => [
649
+ step,
650
+ [...path, 'steps', stepId],
651
+ ]);
652
+ for (const [step, stepPath] of steps) {
653
+ validateSubPageLayout(step.layout, step.blocks, report, stepPath, breadcrumbsEnabled);
561
654
  }
655
+ validateConsistentBreadcrumbsGridArea(steps, report);
562
656
  return;
563
657
  }
564
658
  // Loop page
565
659
  if (page.type === 'loop') {
566
660
  const loopPage = page;
567
- if (loopPage.start) {
568
- validateSubPageLayout(loopPage.start.layout, loopPage.start.blocks, report, [
569
- ...path,
570
- 'start',
571
- ]);
572
- }
573
- validateSubPageLayout(loopPage.foreach.layout, loopPage.foreach.blocks, report, [
574
- ...path,
575
- 'foreach',
576
- ]);
577
- if (loopPage.end) {
578
- validateSubPageLayout(loopPage.end.layout, loopPage.end.blocks, report, [...path, 'end']);
661
+ const subPages = [
662
+ [loopPage.start, 'start'],
663
+ [loopPage.foreach, 'foreach'],
664
+ [loopPage.end, 'end'],
665
+ ].flatMap(([subPage, key]) => subPage ? [[subPage, [...path, key]]] : []);
666
+ for (const [subPage, subPagePath] of subPages) {
667
+ validateSubPageLayout(subPage.layout, subPage.blocks, report, subPagePath, breadcrumbsEnabled);
579
668
  }
669
+ validateConsistentBreadcrumbsGridArea(subPages, report);
580
670
  }
581
671
  },
582
672
  });
@@ -1069,6 +1159,13 @@ function validateSecurity(definition, report) {
1069
1159
  }
1070
1160
  }
1071
1161
  iterApp(definition, { onBlock: checkRoles, onPage: checkRoles });
1162
+ if (definition.pages) {
1163
+ iterPageParents(definition.pages, (entry, path) => {
1164
+ if (typeof entry !== 'string') {
1165
+ checkRoles(entry, path);
1166
+ }
1167
+ });
1168
+ }
1072
1169
  const { hideGroupDropdown } = definition.layout ?? {};
1073
1170
  if (Array.isArray(hideGroupDropdown)) {
1074
1171
  for (const [index, role] of hideGroupDropdown.entries()) {
@@ -1573,6 +1670,7 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
1573
1670
  validateLanguage(clonedDefinition, report);
1574
1671
  validateResourceReferences(clonedDefinition, report);
1575
1672
  validateMembersSchema(clonedDefinition, report);
1673
+ validatePageParents(clonedDefinition, report);
1576
1674
  validatePhoneNumberDefinition(clonedDefinition, report);
1577
1675
  validateResourceSchemas(clonedDefinition, report);
1578
1676
  validateSecurity(clonedDefinition, report);