@appsemble/lang-sdk 0.40.1 → 0.40.3-test.3

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 CHANGED
@@ -1,8 +1,16 @@
1
- import { type GridLayoutDefinition, type PageDefinition, type PageLayoutDefinition, type ResponsiveGridLayoutDefinition } from './types/index.js';
1
+ import { type BuiltinPagesLayoutDefinition, type GridLayoutDefinition, type PageDefinition, type PageLayoutDefinition, type ResponsiveGridLayoutDefinition } from './types/index.js';
2
2
  /**
3
3
  * The template area a page grid layout uses to place the breadcrumb trail.
4
4
  */
5
5
  export declare const breadcrumbsGridArea = "breadcrumbs";
6
+ /**
7
+ * The template area a grid layout uses to place the banner that asks to verify the email address.
8
+ */
9
+ export declare const resendBannerGridArea = "resend-banner";
10
+ /**
11
+ * The template area a grid layout uses to place the bottom navigation.
12
+ */
13
+ export declare const bottomNavigationGridArea = "bottom-navigation";
6
14
  /**
7
15
  * The devices a responsive grid layout is defined for, from the smallest breakpoint up.
8
16
  */
@@ -15,6 +23,18 @@ export type GridDeviceName = (typeof gridDeviceOrder)[number];
15
23
  * The grid a device falls back to when neither it nor a smaller device defines one.
16
24
  */
17
25
  export declare const defaultGridLayout: Readonly<GridLayoutDefinition>;
26
+ /**
27
+ * The template area a built-in page grid layout uses to place the heading of the page.
28
+ */
29
+ export declare const builtinPagesTitleGridArea = "title";
30
+ /**
31
+ * The template area a built-in page grid layout uses to place the content of the page.
32
+ */
33
+ export declare const builtinPagesContentGridArea = "content";
34
+ /**
35
+ * The template areas a built-in page grid layout may name, in DOM order.
36
+ */
37
+ export declare const builtinPagesGridAreaOrder: readonly ["resend-banner", "title", "content", "bottom-navigation"];
18
38
  /**
19
39
  * Collect the template areas a responsive grid layout defines.
20
40
  *
@@ -37,27 +57,52 @@ export declare function getPageGridLayouts(page: PageDefinition): (PageLayoutDef
37
57
  * Collect the template areas each device renders.
38
58
  *
39
59
  * 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.
60
+ * the app applies the grid of a page, down to the given default layout: `defaultGridLayout` unless
61
+ * the caller passes its own. So the areas a device renders are not the areas its own template names.
42
62
  *
43
63
  * @param layout The responsive grid layout to read.
64
+ * @param defaultLayout The grid a device falls back to below the smallest defined template.
44
65
  * @returns The areas rendered by each device, keyed by device name.
45
66
  */
46
- export declare function getCascadedGridTemplateAreas(layout: ResponsiveGridLayoutDefinition | undefined): Record<GridDeviceName, Set<string>>;
67
+ export declare function getCascadedGridTemplateAreas(layout: ResponsiveGridLayoutDefinition | undefined, defaultLayout?: Readonly<GridLayoutDefinition>): Record<GridDeviceName, Set<string>>;
68
+ /**
69
+ * Check whether a built-in page grid layout places an area.
70
+ *
71
+ * An optional area is named by the template of every device or by the template of none, so any
72
+ * device that names it opts every breakpoint in.
73
+ *
74
+ * @param layout The built-in page grid layout to check.
75
+ * @param area The template area to look for.
76
+ * @returns Whether the layout names the template area.
77
+ */
78
+ export declare function hasBuiltinPagesGridArea(layout: BuiltinPagesLayoutDefinition | undefined, area: string): boolean;
79
+ /**
80
+ * Build the grid built-in pages fall back to below the smallest defined template.
81
+ *
82
+ * `defaultGridLayout` names the `main` area, which no built-in page renders. Built-in pages stack
83
+ * the areas their templates name instead, in DOM order, so an app can define the desktop grid alone
84
+ * without an invalid mobile fallback or an accidental heading.
85
+ *
86
+ * @param layout The built-in page grid layout to read the areas of.
87
+ * @returns A single column grid with one row per area the layout names.
88
+ */
89
+ export declare function getBuiltinPagesDefaultGridLayout(layout: BuiltinPagesLayoutDefinition | undefined): GridLayoutDefinition;
47
90
  /**
48
- * Check whether a grid layout places the breadcrumb trail.
91
+ * Check whether a grid layout places an area Appsemble renders itself.
49
92
  *
50
- * Every device has to render the area, since a device that does not would place the trail against
93
+ * Every device has to render the area, since a device that does not would place the element against
51
94
  * implicit grid lines rather than inside the layout.
52
95
  *
53
96
  * @param layout The responsive grid layout to check.
54
- * @returns Whether every device renders the breadcrumbs template area.
97
+ * @param area The template area to look for.
98
+ * @returns Whether every device renders the template area.
55
99
  */
56
- export declare function hasBreadcrumbsGridArea(layout: PageLayoutDefinition | undefined): boolean;
100
+ export declare function hasGridArea(layout: PageLayoutDefinition | undefined, area: string): boolean;
57
101
  /**
58
- * Check whether a page renders the breadcrumb trail from a grid layout.
102
+ * Check whether a page renders an area Appsemble renders itself from a grid layout.
59
103
  *
60
104
  * @param page The page to check the grid layouts of.
61
- * @returns Whether any grid layout of the page places the breadcrumb trail.
105
+ * @param area The template area to look for.
106
+ * @returns Whether any grid layout of the page places the area.
62
107
  */
63
- export declare function pageHasBreadcrumbsGridArea(page: PageDefinition): boolean;
108
+ export declare function pageHasGridArea(page: PageDefinition, area: string): boolean;
package/gridUtils.js CHANGED
@@ -2,6 +2,14 @@
2
2
  * The template area a page grid layout uses to place the breadcrumb trail.
3
3
  */
4
4
  export const breadcrumbsGridArea = 'breadcrumbs';
5
+ /**
6
+ * The template area a grid layout uses to place the banner that asks to verify the email address.
7
+ */
8
+ export const resendBannerGridArea = 'resend-banner';
9
+ /**
10
+ * The template area a grid layout uses to place the bottom navigation.
11
+ */
12
+ export const bottomNavigationGridArea = 'bottom-navigation';
5
13
  /**
6
14
  * The devices a responsive grid layout is defined for, from the smallest breakpoint up.
7
15
  */
@@ -13,6 +21,23 @@ export const defaultGridLayout = {
13
21
  columns: 1,
14
22
  template: ['main'],
15
23
  };
24
+ /**
25
+ * The template area a built-in page grid layout uses to place the heading of the page.
26
+ */
27
+ export const builtinPagesTitleGridArea = 'title';
28
+ /**
29
+ * The template area a built-in page grid layout uses to place the content of the page.
30
+ */
31
+ export const builtinPagesContentGridArea = 'content';
32
+ /**
33
+ * The template areas a built-in page grid layout may name, in DOM order.
34
+ */
35
+ export const builtinPagesGridAreaOrder = [
36
+ resendBannerGridArea,
37
+ builtinPagesTitleGridArea,
38
+ builtinPagesContentGridArea,
39
+ bottomNavigationGridArea,
40
+ ];
16
41
  function getTemplateAreas(template) {
17
42
  const areas = new Set();
18
43
  for (const row of template) {
@@ -74,41 +99,74 @@ export function getPageGridLayouts(page) {
74
99
  * Collect the template areas each device renders.
75
100
  *
76
101
  * 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.
102
+ * the app applies the grid of a page, down to the given default layout: `defaultGridLayout` unless
103
+ * the caller passes its own. So the areas a device renders are not the areas its own template names.
79
104
  *
80
105
  * @param layout The responsive grid layout to read.
106
+ * @param defaultLayout The grid a device falls back to below the smallest defined template.
81
107
  * @returns The areas rendered by each device, keyed by device name.
82
108
  */
83
- export function getCascadedGridTemplateAreas(layout) {
84
- let { template } = defaultGridLayout;
109
+ export function getCascadedGridTemplateAreas(layout, defaultLayout = defaultGridLayout) {
110
+ let { template } = defaultLayout;
85
111
  return Object.fromEntries(gridDeviceOrder.map((device) => {
86
112
  template = layout?.[device]?.layout?.template ?? template;
87
113
  return [device, getTemplateAreas(template)];
88
114
  }));
89
115
  }
90
116
  /**
91
- * Check whether a grid layout places the breadcrumb trail.
117
+ * Check whether a built-in page grid layout places an area.
118
+ *
119
+ * An optional area is named by the template of every device or by the template of none, so any
120
+ * device that names it opts every breakpoint in.
121
+ *
122
+ * @param layout The built-in page grid layout to check.
123
+ * @param area The template area to look for.
124
+ * @returns Whether the layout names the template area.
125
+ */
126
+ export function hasBuiltinPagesGridArea(layout, area) {
127
+ return getGridTemplateAreas(layout).has(area);
128
+ }
129
+ /**
130
+ * Build the grid built-in pages fall back to below the smallest defined template.
131
+ *
132
+ * `defaultGridLayout` names the `main` area, which no built-in page renders. Built-in pages stack
133
+ * the areas their templates name instead, in DOM order, so an app can define the desktop grid alone
134
+ * without an invalid mobile fallback or an accidental heading.
135
+ *
136
+ * @param layout The built-in page grid layout to read the areas of.
137
+ * @returns A single column grid with one row per area the layout names.
138
+ */
139
+ export function getBuiltinPagesDefaultGridLayout(layout) {
140
+ const areas = getGridTemplateAreas(layout);
141
+ return {
142
+ columns: 1,
143
+ template: builtinPagesGridAreaOrder.filter((area) => area === builtinPagesContentGridArea || areas.has(area)),
144
+ };
145
+ }
146
+ /**
147
+ * Check whether a grid layout places an area Appsemble renders itself.
92
148
  *
93
- * Every device has to render the area, since a device that does not would place the trail against
149
+ * Every device has to render the area, since a device that does not would place the element against
94
150
  * implicit grid lines rather than inside the layout.
95
151
  *
96
152
  * @param layout The responsive grid layout to check.
97
- * @returns Whether every device renders the breadcrumbs template area.
153
+ * @param area The template area to look for.
154
+ * @returns Whether every device renders the template area.
98
155
  */
99
- export function hasBreadcrumbsGridArea(layout) {
156
+ export function hasGridArea(layout, area) {
100
157
  if (!layout) {
101
158
  return false;
102
159
  }
103
- return Object.values(getCascadedGridTemplateAreas(layout)).every((areas) => areas.has(breadcrumbsGridArea));
160
+ return Object.values(getCascadedGridTemplateAreas(layout)).every((areas) => areas.has(area));
104
161
  }
105
162
  /**
106
- * Check whether a page renders the breadcrumb trail from a grid layout.
163
+ * Check whether a page renders an area Appsemble renders itself from a grid layout.
107
164
  *
108
165
  * @param page The page to check the grid layouts of.
109
- * @returns Whether any grid layout of the page places the breadcrumb trail.
166
+ * @param area The template area to look for.
167
+ * @returns Whether any grid layout of the page places the area.
110
168
  */
111
- export function pageHasBreadcrumbsGridArea(page) {
112
- return getPageGridLayouts(page).some(hasBreadcrumbsGridArea);
169
+ export function pageHasGridArea(page, area) {
170
+ return getPageGridLayouts(page).some((layout) => hasGridArea(layout, area));
113
171
  }
114
172
  //# sourceMappingURL=gridUtils.js.map
package/icons.d.ts CHANGED
@@ -54,8 +54,9 @@ export type ResolvedIcon = {
54
54
  /**
55
55
  * Resolve an icon reference against an icon registry.
56
56
  *
57
- * Registry keys and entries are validated here as well, so the result is safe to render even if
58
- * the registry wasn’t validated when it was published. Only own properties of the registry count.
57
+ * A bare name listed in an entry’s `overrides` resolves like `icon:<key>` for that entry. Registry
58
+ * keys and entries are validated here as well, so the result is safe to render even if the
59
+ * registry wasn’t validated when it was published. Only own properties of the registry count.
59
60
  *
60
61
  * @param reference The icon reference to resolve.
61
62
  * @param registry The app’s icon registry.
package/icons.js CHANGED
@@ -61,8 +61,9 @@ export function isValidIconReference(reference) {
61
61
  /**
62
62
  * Resolve an icon reference against an icon registry.
63
63
  *
64
- * Registry keys and entries are validated here as well, so the result is safe to render even if
65
- * the registry wasn’t validated when it was published. Only own properties of the registry count.
64
+ * A bare name listed in an entry’s `overrides` resolves like `icon:<key>` for that entry. Registry
65
+ * keys and entries are validated here as well, so the result is safe to render even if the
66
+ * registry wasn’t validated when it was published. Only own properties of the registry count.
66
67
  *
67
68
  * @param reference The icon reference to resolve.
68
69
  * @param registry The app’s icon registry.
@@ -70,6 +71,15 @@ export function isValidIconReference(reference) {
70
71
  */
71
72
  export function resolveIconReference(reference, registry) {
72
73
  const parsed = parseIconReference(reference);
74
+ if (parsed.type === 'fontawesome' && typeof registry === 'object' && registry) {
75
+ const override = Object.keys(registry).find((key) => {
76
+ const overrides = registry[key]?.overrides;
77
+ return Array.isArray(overrides) && overrides.includes(parsed.name);
78
+ });
79
+ if (override !== undefined) {
80
+ return resolveIconReference(`${customIconPrefix}${override}`, registry);
81
+ }
82
+ }
73
83
  if (parsed.type !== 'custom') {
74
84
  return parsed;
75
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/lang-sdk",
3
- "version": "0.40.1",
3
+ "version": "0.40.3-test.3",
4
4
  "description": "Language SDK for Appsemble",
5
5
  "keywords": [
6
6
  "app",
@@ -51,7 +51,7 @@
51
51
  "language-tags": "^1.0.0",
52
52
  "lcm": "^0.0.4",
53
53
  "openapi-types": "^12.0.0",
54
- "parse-duration": "^1.0.0",
54
+ "parse-duration": "^2.0.0",
55
55
  "type-fest": "^4.0.0",
56
56
  "yaml": "^2.0.0"
57
57
  },
@@ -15,6 +15,7 @@ import { MessageActionDefinition } from '../../schemas/actions/MessageActionDefi
15
15
  import { NoopActionDefinition } from '../../schemas/actions/NoopActionDefinition.js';
16
16
  import { NotifyActionDefinition } from '../../schemas/actions/NotifyActionDefinition.js';
17
17
  import { RequestActionDefinition } from '../../schemas/actions/RequestActionDefinition.js';
18
+ import { ScrollActionDefinition } from '../../schemas/actions/ScrollActionDefinition.js';
18
19
  import { ShareActionDefinition } from '../../schemas/actions/ShareActionDefinition.js';
19
20
  import { StaticActionDefinition } from '../../schemas/actions/StaticActionDefinition.js';
20
21
  import { ThrowActionDefinition } from '../../schemas/actions/ThrowActionDefinition.js';
@@ -37,6 +38,7 @@ export const miscellaneousActions = {
37
38
  noop: NoopActionDefinition,
38
39
  notify: NotifyActionDefinition,
39
40
  request: RequestActionDefinition,
41
+ scroll: ScrollActionDefinition,
40
42
  share: ShareActionDefinition,
41
43
  static: StaticActionDefinition,
42
44
  throw: ThrowActionDefinition,
@@ -97,7 +97,7 @@ The most basic resource has a \`schema\` property and defines the minimal securi
97
97
 
98
98
  Each key names an app-level SVG asset by its name. Keys and asset names must consist of lower case
99
99
  letters, digits, and single hyphens. Keys are case-sensitive, and \`icon:<key>\` never falls back
100
- to a Font Awesome icon.
100
+ to a Font Awesome icon. Use \`overrides\` to replace Font Awesome icons with a custom icon.
101
101
  `,
102
102
  additionalProperties: { $ref: '#/components/schemas/IconRegistryEntryDefinition' },
103
103
  },
@@ -118,6 +118,14 @@ Only applies when \`navigation\` is set to \`top\`.
118
118
  description: `Responsive grid layout for the top navigation header.
119
119
 
120
120
  Only applies when \`navigation\` is set to \`top\` and cannot be combined with \`stackedHeader\`.
121
+ `,
122
+ },
123
+ builtinPages: {
124
+ $ref: '#/components/schemas/BuiltinPagesLayoutDefinition',
125
+ description: `Responsive grid layout for the built-in pages of the app.
126
+
127
+ Applies unchanged to every built-in page and every state of it. The available template areas are
128
+ \`resend-banner\`, \`title\`, \`content\`, and \`bottom-navigation\`.
121
129
  `,
122
130
  },
123
131
  breakpoints: {
@@ -51,8 +51,8 @@ navigation. Set to \`hidden\` to display no navigational menus at all.
51
51
  format: 'icon',
52
52
  description: `An optional icon from the Font Awesome icon set, or a custom icon reference.
53
53
 
54
- This will be displayed in the navigation menu. Use \`icon:<key>\` to reference a key from the app’s
55
- \`icons\` registry.
54
+ This will be displayed in the navigation menu and the breadcrumb trail. Use \`icon:<key>\` to
55
+ reference a key from the app’s \`icons\` registry.
56
56
  `,
57
57
  },
58
58
  parent: {
@@ -91,10 +91,7 @@ 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.
95
-
96
- The \`breadcrumbs\` area is reserved for the breadcrumb trail of the app.
97
- `,
94
+ description: 'Grid area defined in the page layout definition.',
98
95
  },
99
96
  },
100
97
  };
@@ -0,0 +1 @@
1
+ export declare const BuiltinPagesLayoutDefinition: import("openapi-types").OpenAPIV3.SchemaObject;
@@ -0,0 +1,26 @@
1
+ import { ResponsiveGridLayoutDefinition } from './ResponsiveGridLayoutDefinition.js';
2
+ import { extendJSONSchema } from './utils/extendJSONSchema.js';
3
+ export const BuiltinPagesLayoutDefinition = extendJSONSchema(ResponsiveGridLayoutDefinition, {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ minProperties: 1,
7
+ description: `Responsive grid layout for the built-in pages of the app.
8
+
9
+ The layout applies unchanged to every built-in page and to every state of it. Without it, no
10
+ built-in page becomes a grid or gains a heading.
11
+
12
+ The available template areas are \`resend-banner\`, \`title\`, \`content\`, and \`bottom-navigation\`.
13
+ \`content\` holds everything the page renders for its current state and has to be part of the grid
14
+ every breakpoint renders. The others are optional and have to be named by the grid of every
15
+ breakpoint or of none: \`title\` is a heading that renders the name of the page, \`resend-banner\`
16
+ places the banner that asks to verify the email address, and \`bottom-navigation\` places the bottom
17
+ navigation. Without their area the banner renders above the page and the bottom navigation is fixed
18
+ to the bottom of the viewport; with it, they render nothing when the app shows none. The first
19
+ visual occurrence of the areas has to follow that order, so that keyboard, screen reader, and visual
20
+ order agree.
21
+
22
+ Breakpoints before the first one the layout defines render a single column with one row per area,
23
+ in that same order.
24
+ `,
25
+ });
26
+ //# sourceMappingURL=BuiltinPagesLayoutDefinition.js.map
@@ -12,6 +12,15 @@ export const IconRegistryEntryDefinition = {
12
12
 
13
13
  This is an asset name, not a URL or asset ID. The asset doesn’t need to exist when the app is
14
14
  published, but the icon only renders once an SVG asset with this name has been uploaded.
15
+ `,
16
+ },
17
+ overrides: {
18
+ type: 'array',
19
+ items: { type: 'string', format: 'fontawesome' },
20
+ uniqueItems: true,
21
+ description: `Font Awesome icon names to replace with this icon everywhere in the app.
22
+
23
+ Use the name from the icon’s \`fa-<name>\` class. A name can only be listed once.
15
24
  `,
16
25
  },
17
26
  },
@@ -6,9 +6,13 @@ export const PageLayoutDefinition = extendJSONSchema(ResponsiveGridLayoutDefinit
6
6
  minProperties: 1,
7
7
  description: `Responsive grid layout for a page.
8
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.
9
+ The \`breadcrumbs\`, \`resend-banner\`, and \`bottom-navigation\` template areas are reserved for the
10
+ breadcrumb trail, the banner that asks to verify the email address, and the bottom navigation of the
11
+ app. Each has to be part of the grid every breakpoint renders and has to be defined by every sub
12
+ page of a page or by none of them. \`breadcrumbs\` requires \`layout.breadcrumbs\`. Without a reserved
13
+ area the element keeps its default position: the trail below the title bar, the banner above the
14
+ page, and the bottom navigation fixed to the bottom of the viewport. \`resend-banner\` and
15
+ \`bottom-navigation\` render nothing when the app shows no banner or no bottom navigation.
12
16
  `,
13
17
  });
14
18
  //# sourceMappingURL=PageLayoutDefinition.js.map
@@ -66,6 +66,7 @@ export const ActionDefinition = {
66
66
  { $ref: '#/components/schemas/ResourceUpdateGroupActionDefinition' },
67
67
  { $ref: '#/components/schemas/ResourceUpdatePositionsActionDefinition' },
68
68
  { $ref: '#/components/schemas/ResourcePatchActionDefinition' },
69
+ { $ref: '#/components/schemas/ScrollActionDefinition' },
69
70
  { $ref: '#/components/schemas/ShareActionDefinition' },
70
71
  { $ref: '#/components/schemas/StaticActionDefinition' },
71
72
  { $ref: '#/components/schemas/StorageAppendActionDefinition' },
@@ -0,0 +1 @@
1
+ export declare const ScrollActionDefinition: import("openapi-types").OpenAPIV3.SchemaObject;
@@ -0,0 +1,18 @@
1
+ import { BaseActionDefinition } from './BaseActionDefinition.js';
2
+ import { extendJSONSchema } from '../utils/extendJSONSchema.js';
3
+ export const ScrollActionDefinition = extendJSONSchema(BaseActionDefinition, {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ required: ['type', 'to'],
7
+ properties: {
8
+ type: {
9
+ enum: ['scroll'],
10
+ description: 'Scroll the page to the top or the bottom.',
11
+ },
12
+ to: {
13
+ enum: ['top', 'bottom'],
14
+ description: 'Where on the page to scroll to.',
15
+ },
16
+ },
17
+ });
18
+ //# sourceMappingURL=ScrollActionDefinition.js.map
@@ -57,6 +57,7 @@ export * from './actions/ResourceSubscriptionUnsubscribeActionDefinition.js';
57
57
  export * from './actions/ResourceUpdateActionDefinition.js';
58
58
  export * from './actions/ResourceUpdateGroupActionDefinition.js';
59
59
  export * from './actions/ResourceUpdatePositionsActionDefinition.js';
60
+ export * from './actions/ScrollActionDefinition.js';
60
61
  export * from './actions/ShareActionDefinition.js';
61
62
  export * from './actions/StaticActionDefinition.js';
62
63
  export * from './actions/StorageAppendActionDefinition.js';
@@ -128,6 +129,7 @@ export * from './GridBreakpointsDefinition.js';
128
129
  export * from './GridLayoutDefinition.js';
129
130
  export * from './GridSpacingDefinition.js';
130
131
  export * from './ResponsiveGridLayoutDefinition.js';
132
+ export * from './BuiltinPagesLayoutDefinition.js';
131
133
  export * from './NavbarDeviceGridLayoutDefinition.js';
132
134
  export * from './NavbarLayoutDefinition.js';
133
135
  export * from './PageLayoutDefinition.js';
package/schemas/index.js CHANGED
@@ -57,6 +57,7 @@ export * from './actions/ResourceSubscriptionUnsubscribeActionDefinition.js';
57
57
  export * from './actions/ResourceUpdateActionDefinition.js';
58
58
  export * from './actions/ResourceUpdateGroupActionDefinition.js';
59
59
  export * from './actions/ResourceUpdatePositionsActionDefinition.js';
60
+ export * from './actions/ScrollActionDefinition.js';
60
61
  export * from './actions/ShareActionDefinition.js';
61
62
  export * from './actions/StaticActionDefinition.js';
62
63
  export * from './actions/StorageAppendActionDefinition.js';
@@ -128,6 +129,7 @@ export * from './GridBreakpointsDefinition.js';
128
129
  export * from './GridLayoutDefinition.js';
129
130
  export * from './GridSpacingDefinition.js';
130
131
  export * from './ResponsiveGridLayoutDefinition.js';
132
+ export * from './BuiltinPagesLayoutDefinition.js';
131
133
  export * from './NavbarDeviceGridLayoutDefinition.js';
132
134
  export * from './NavbarLayoutDefinition.js';
133
135
  export * from './PageLayoutDefinition.js';
package/types/action.d.ts CHANGED
@@ -48,5 +48,5 @@ export type ResourcePatchAction = RequestLikeAction<'resource.patch'>;
48
48
  /**
49
49
  * An action that can be called from within a block.
50
50
  */
51
- export type Action = BaseAction<'analytics'> | BaseAction<'app.member.current.patch'> | BaseAction<'app.member.delete'> | BaseAction<'app.member.invite'> | BaseAction<'app.member.login'> | BaseAction<'app.member.logout'> | BaseAction<'app.member.properties.patch'> | BaseAction<'app.member.query'> | BaseAction<'app.member.register'> | BaseAction<'app.member.role.update'> | BaseAction<'condition'> | BaseAction<'controller'> | BaseAction<'csv.parse'> | BaseAction<'dialog.error'> | BaseAction<'dialog.ok'> | BaseAction<'dialog'> | BaseAction<'download'> | BaseAction<'each'> | BaseAction<'email'> | BaseAction<'event'> | BaseAction<'flow.back'> | BaseAction<'flow.cancel'> | BaseAction<'flow.finish'> | BaseAction<'flow.next'> | BaseAction<'flow.to'> | BaseAction<'group.member.create'> | BaseAction<'group.member.delete'> | BaseAction<'group.member.invite'> | BaseAction<'group.member.query'> | BaseAction<'group.member.role.update'> | BaseAction<'group.query'> | BaseAction<'group.selected.update'> | BaseAction<'link.back'> | BaseAction<'link.next'> | BaseAction<'match'> | BaseAction<'message'> | BaseAction<'noop'> | BaseAction<'notify'> | BaseAction<'resource.subscription.status'> | BaseAction<'resource.subscription.subscribe'> | BaseAction<'resource.subscription.toggle'> | BaseAction<'resource.subscription.unsubscribe'> | BaseAction<'share'> | BaseAction<'static'> | BaseAction<'storage.append'> | BaseAction<'storage.delete'> | BaseAction<'storage.read'> | BaseAction<'storage.subtract'> | BaseAction<'storage.update'> | BaseAction<'storage.write'> | BaseAction<'throw'> | BaseAction<'webhook'> | LinkAction | LogAction | RequestAction | ResourceCountAction | ResourceCreateAction | ResourceDeleteAction | ResourceDeleteAllAction | ResourceDeleteBulkAction | ResourceGetAction | ResourceHistoryGetAction | ResourcePatchAction | ResourceQueryAction | ResourceUpdateAction | ResourceUpdateGroupAction | ResourceUpdatePositionsAction;
51
+ export type Action = BaseAction<'analytics'> | BaseAction<'app.member.current.patch'> | BaseAction<'app.member.delete'> | BaseAction<'app.member.invite'> | BaseAction<'app.member.login'> | BaseAction<'app.member.logout'> | BaseAction<'app.member.properties.patch'> | BaseAction<'app.member.query'> | BaseAction<'app.member.register'> | BaseAction<'app.member.role.update'> | BaseAction<'condition'> | BaseAction<'controller'> | BaseAction<'csv.parse'> | BaseAction<'dialog.error'> | BaseAction<'dialog.ok'> | BaseAction<'dialog'> | BaseAction<'download'> | BaseAction<'each'> | BaseAction<'email'> | BaseAction<'event'> | BaseAction<'flow.back'> | BaseAction<'flow.cancel'> | BaseAction<'flow.finish'> | BaseAction<'flow.next'> | BaseAction<'flow.to'> | BaseAction<'group.member.create'> | BaseAction<'group.member.delete'> | BaseAction<'group.member.invite'> | BaseAction<'group.member.query'> | BaseAction<'group.member.role.update'> | BaseAction<'group.query'> | BaseAction<'group.selected.update'> | BaseAction<'link.back'> | BaseAction<'link.next'> | BaseAction<'match'> | BaseAction<'message'> | BaseAction<'noop'> | BaseAction<'notify'> | BaseAction<'resource.subscription.status'> | BaseAction<'resource.subscription.subscribe'> | BaseAction<'resource.subscription.toggle'> | BaseAction<'resource.subscription.unsubscribe'> | BaseAction<'scroll'> | BaseAction<'share'> | BaseAction<'static'> | BaseAction<'storage.append'> | BaseAction<'storage.delete'> | BaseAction<'storage.read'> | BaseAction<'storage.subtract'> | BaseAction<'storage.update'> | BaseAction<'storage.write'> | BaseAction<'throw'> | BaseAction<'webhook'> | LinkAction | LogAction | RequestAction | ResourceCountAction | ResourceCreateAction | ResourceDeleteAction | ResourceDeleteAllAction | ResourceDeleteBulkAction | ResourceGetAction | ResourceHistoryGetAction | ResourcePatchAction | ResourceQueryAction | ResourceUpdateAction | ResourceUpdateGroupAction | ResourceUpdatePositionsAction;
52
52
  export {};
@@ -3,8 +3,8 @@ import { type BlockDefinition } from './block.js';
3
3
  import { type BulmaColor } from './bulma.js';
4
4
  import { type HTTPMethods } from './http.js';
5
5
  import { type Remapper } from './remapper.js';
6
- export type ActionDefinition = AnalyticsAction | AppMemberCurrentPatchAction | AppMemberDeleteAction | AppMemberInviteAction | AppMemberLoginAction | AppMemberLogoutAction | AppMemberPropertiesPatchAction | AppMemberQueryAction | AppMemberRegisterAction | AppMemberRoleUpdateAction | BaseActionDefinition<'dialog.error'> | BaseActionDefinition<'dialog.ok'> | BaseActionDefinition<'flow.back'> | BaseActionDefinition<'flow.cancel'> | BaseActionDefinition<'flow.finish'> | BaseActionDefinition<'flow.next'> | BaseActionDefinition<'group.query'> | BaseActionDefinition<'link.back'> | BaseActionDefinition<'link.next'> | BaseActionDefinition<'noop'> | BaseActionDefinition<'throw'> | ConditionActionDefinition | ControllerActionDefinition | CsvParserActionDefinition | DialogActionDefinition | DownloadActionDefinition | EachActionDefinition | EmailActionDefinition | EventActionDefinition | FlowToActionDefinition | GroupMemberCreateActionDefinition | GroupMemberDeleteActionDefinition | GroupMemberInviteActionDefinition | GroupMemberQueryActionDefinition | GroupMemberRoleUpdateActionDefinition | GroupSelectedUpdateActionDefinition | LinkActionDefinition | LogActionDefinition | MatchActionDefinition | MessageActionDefinition | NotifyActionDefinition | RequestActionDefinition | ResourceCountActionDefinition | ResourceCreateActionDefinition | ResourceDeleteActionDefinition | ResourceDeleteAllActionDefinition | ResourceDeleteBulkActionDefinition | ResourceGetActionDefinition | ResourceHistoryGetActionDefinition | ResourcePatchActionDefinition | ResourceQueryActionDefinition | ResourceSubscriptionStatusActionDefinition | ResourceSubscriptionSubscribeActionDefinition | ResourceSubscriptionToggleActionDefinition | ResourceSubscriptionUnsubscribeActionDefinition | ResourceUpdateActionDefinition | ResourceUpdateGroupActionDefinition | ResourceUpdatePositionsActionDefinition | ShareActionDefinition | StaticActionDefinition | StorageAppendActionDefinition | StorageDeleteActionDefinition | StorageReadActionDefinition | StorageSubtractActionDefinition | StorageUpdateActionDefinition | StorageWriteActionDefinition | WebhookActionDefinition;
7
- export type ActionName = 'analytics' | 'app.member.current.patch' | 'app.member.delete' | 'app.member.invite' | 'app.member.login' | 'app.member.logout' | 'app.member.properties.patch' | 'app.member.query' | 'app.member.register' | 'app.member.role.update' | 'condition' | 'controller' | 'csv.parse' | 'dialog.error' | 'dialog.ok' | 'dialog' | 'download' | 'each' | 'email' | 'event' | 'flow.back' | 'flow.cancel' | 'flow.finish' | 'flow.next' | 'flow.to' | 'group.member.create' | 'group.member.delete' | 'group.member.invite' | 'group.member.query' | 'group.member.role.update' | 'group.query' | 'group.selected.update' | 'link.back' | 'link.next' | 'link' | 'log' | 'match' | 'message' | 'noop' | 'notify' | 'request' | 'resource.count' | 'resource.create' | 'resource.delete.all' | 'resource.delete.bulk' | 'resource.delete' | 'resource.get' | 'resource.history.get' | 'resource.patch' | 'resource.query' | 'resource.subscription.status' | 'resource.subscription.subscribe' | 'resource.subscription.toggle' | 'resource.subscription.unsubscribe' | 'resource.update.group' | 'resource.update.positions' | 'resource.update' | 'share' | 'static' | 'storage.append' | 'storage.delete' | 'storage.read' | 'storage.subtract' | 'storage.update' | 'storage.write' | 'throw' | 'webhook';
6
+ export type ActionDefinition = AnalyticsAction | AppMemberCurrentPatchAction | AppMemberDeleteAction | AppMemberInviteAction | AppMemberLoginAction | AppMemberLogoutAction | AppMemberPropertiesPatchAction | AppMemberQueryAction | AppMemberRegisterAction | AppMemberRoleUpdateAction | BaseActionDefinition<'dialog.error'> | BaseActionDefinition<'dialog.ok'> | BaseActionDefinition<'flow.back'> | BaseActionDefinition<'flow.cancel'> | BaseActionDefinition<'flow.finish'> | BaseActionDefinition<'flow.next'> | BaseActionDefinition<'group.query'> | BaseActionDefinition<'link.back'> | BaseActionDefinition<'link.next'> | BaseActionDefinition<'noop'> | BaseActionDefinition<'throw'> | ConditionActionDefinition | ControllerActionDefinition | CsvParserActionDefinition | DialogActionDefinition | DownloadActionDefinition | EachActionDefinition | EmailActionDefinition | EventActionDefinition | FlowToActionDefinition | GroupMemberCreateActionDefinition | GroupMemberDeleteActionDefinition | GroupMemberInviteActionDefinition | GroupMemberQueryActionDefinition | GroupMemberRoleUpdateActionDefinition | GroupSelectedUpdateActionDefinition | LinkActionDefinition | LogActionDefinition | MatchActionDefinition | MessageActionDefinition | NotifyActionDefinition | RequestActionDefinition | ResourceCountActionDefinition | ResourceCreateActionDefinition | ResourceDeleteActionDefinition | ResourceDeleteAllActionDefinition | ResourceDeleteBulkActionDefinition | ResourceGetActionDefinition | ResourceHistoryGetActionDefinition | ResourcePatchActionDefinition | ResourceQueryActionDefinition | ResourceSubscriptionStatusActionDefinition | ResourceSubscriptionSubscribeActionDefinition | ResourceSubscriptionToggleActionDefinition | ResourceSubscriptionUnsubscribeActionDefinition | ResourceUpdateActionDefinition | ResourceUpdateGroupActionDefinition | ResourceUpdatePositionsActionDefinition | ScrollActionDefinition | ShareActionDefinition | StaticActionDefinition | StorageAppendActionDefinition | StorageDeleteActionDefinition | StorageReadActionDefinition | StorageSubtractActionDefinition | StorageUpdateActionDefinition | StorageWriteActionDefinition | WebhookActionDefinition;
7
+ export type ActionName = 'analytics' | 'app.member.current.patch' | 'app.member.delete' | 'app.member.invite' | 'app.member.login' | 'app.member.logout' | 'app.member.properties.patch' | 'app.member.query' | 'app.member.register' | 'app.member.role.update' | 'condition' | 'controller' | 'csv.parse' | 'dialog.error' | 'dialog.ok' | 'dialog' | 'download' | 'each' | 'email' | 'event' | 'flow.back' | 'flow.cancel' | 'flow.finish' | 'flow.next' | 'flow.to' | 'group.member.create' | 'group.member.delete' | 'group.member.invite' | 'group.member.query' | 'group.member.role.update' | 'group.query' | 'group.selected.update' | 'link.back' | 'link.next' | 'link' | 'log' | 'match' | 'message' | 'noop' | 'notify' | 'request' | 'resource.count' | 'resource.create' | 'resource.delete.all' | 'resource.delete.bulk' | 'resource.delete' | 'resource.get' | 'resource.history.get' | 'resource.patch' | 'resource.query' | 'resource.subscription.status' | 'resource.subscription.subscribe' | 'resource.subscription.toggle' | 'resource.subscription.unsubscribe' | 'resource.update.group' | 'resource.update.positions' | 'resource.update' | 'scroll' | 'share' | 'static' | 'storage.append' | 'storage.delete' | 'storage.read' | 'storage.subtract' | 'storage.update' | 'storage.write' | 'throw' | 'webhook';
8
8
  export interface BaseActionDefinition<T extends ActionName> {
9
9
  /**
10
10
  * The type of the action.
@@ -203,6 +203,12 @@ export interface LogActionDefinition extends BaseActionDefinition<'log'> {
203
203
  */
204
204
  level?: 'error' | 'info' | 'warn';
205
205
  }
206
+ export interface ScrollActionDefinition extends BaseActionDefinition<'scroll'> {
207
+ /**
208
+ * Where on the page to scroll to.
209
+ */
210
+ to: 'bottom' | 'top';
211
+ }
206
212
  export interface ShareActionDefinition extends BaseActionDefinition<'share'> {
207
213
  /**
208
214
  * The URL that is being shared.
package/types/core.d.ts CHANGED
@@ -9,7 +9,7 @@ import { type ResourceDefinition } from './resource.js';
9
9
  import { type AppRole, type ViewRole } from './roles.js';
10
10
  import { type Security } from './security.js';
11
11
  import { type Theme } from './theme.js';
12
- import { type GridBreakpointsDefinition, type NavbarLayoutDefinition, type PageLayoutDefinition } from './gridLayout.js';
12
+ import { type BuiltinPagesLayoutDefinition, type GridBreakpointsDefinition, type NavbarLayoutDefinition, type PageLayoutDefinition } from './gridLayout.js';
13
13
  export type SettingName = 'email' | 'languages' | 'name' | 'password' | 'phoneNumber' | 'picture';
14
14
  export type AppContentSecurityPolicy = Record<string, string[]>;
15
15
  /**
@@ -24,6 +24,10 @@ export interface IconRegistryEntry {
24
24
  * The name of the app-level asset holding the icon’s SVG artwork.
25
25
  */
26
26
  asset: string;
27
+ /**
28
+ * Font Awesome icons this entry replaces throughout the app.
29
+ */
30
+ overrides?: IconName[];
27
31
  }
28
32
  /**
29
33
  * A mapping of semantic icon keys to the assets holding their artwork.
@@ -149,6 +153,18 @@ export interface AppDefinition {
149
153
  * Each rendered area must be included and their first visual occurrence must follow that order.
150
154
  */
151
155
  navbar?: NavbarLayoutDefinition;
156
+ /**
157
+ * Responsive grid layout for the built-in pages of the app.
158
+ *
159
+ * Applies unchanged to every built-in page and every state of it. Without this, no built-in
160
+ * page becomes a grid or gains a heading.
161
+ *
162
+ * The available template areas are `resend-banner`, `title`, `content`, and
163
+ * `bottom-navigation`. Every rendered breakpoint places `content`, and each of the others is
164
+ * either placed by every breakpoint or by none. Their first visual occurrence must follow that
165
+ * order.
166
+ */
167
+ builtinPages?: BuiltinPagesLayoutDefinition;
152
168
  /**
153
169
  * Minimum viewport widths in pixels at which the tablet and desktop grid layouts apply.
154
170
  *
@@ -283,7 +299,7 @@ export interface BasePageDefinition {
283
299
  * An optional icon from the Font Awesome icon set, or an `icon:<key>` reference to the app’s
284
300
  * `icons` registry.
285
301
  *
286
- * This will be displayed in the navigation menu.
302
+ * This will be displayed in the navigation menu and the breadcrumb trail.
287
303
  */
288
304
  icon?: IconReference;
289
305
  /**
@@ -31,3 +31,4 @@ export interface ResponsiveNavbarGridLayoutDefinition {
31
31
  }
32
32
  export type NavbarLayoutDefinition = RequireAtLeastOne<ResponsiveNavbarGridLayoutDefinition>;
33
33
  export type PageLayoutDefinition = RequireAtLeastOne<ResponsiveGridLayoutDefinition>;
34
+ export type BuiltinPagesLayoutDefinition = RequireAtLeastOne<ResponsiveGridLayoutDefinition>;
package/validation.js CHANGED
@@ -6,7 +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
+ import { bottomNavigationGridArea, breadcrumbsGridArea, builtinPagesGridAreaOrder, getBuiltinPagesDefaultGridLayout, getCascadedGridTemplateAreas, getGridTemplateAreas, gridDeviceOrder, resendBannerGridArea, } from './gridUtils.js';
10
10
  import { isValidIconName, parseIconReference } from './icons.js';
11
11
  import { iterAction, iterApp } from './iterApp.js';
12
12
  import { has } from './miscellaneous.js';
@@ -510,56 +510,140 @@ function validateResponsiveGridLayoutDefinition(layoutDefinition, report, path)
510
510
  ]);
511
511
  }
512
512
  }
513
- function validateBlockGridAreas(blocks, layoutDefinition, report, path) {
514
- const templateAreas = getGridTemplateAreas(layoutDefinition);
515
- for (const [idx, block] of blocks.entries()) {
516
- if (block.gridArea === breadcrumbsGridArea) {
517
- report(block.gridArea, 'is reserved for the breadcrumb trail', [...path, idx, 'gridArea']);
513
+ /**
514
+ * Check the areas of a single grid template against the order the runtime renders them in.
515
+ *
516
+ * Reading the template row by row and cell by cell, the first occurrence of each area has to come
517
+ * in the order the runtime renders them, so that keyboard, screen reader, and visual order agree.
518
+ *
519
+ * @param template The template rows to check.
520
+ * @param rules The areas the template may name and the messages to report with.
521
+ * @param report The report function to use.
522
+ * @param templatePath The path of the template within the app definition.
523
+ */
524
+ function validateGridTemplateAreaOrder(template, rules, report, templatePath) {
525
+ const allowedAreas = new Set(['.', ...rules.areaOrder]);
526
+ const visualAreaOrder = [];
527
+ const visualAreas = new Set();
528
+ for (const [rowIndex, row] of template.entries()) {
529
+ for (const area of row.split(' ')) {
530
+ if (!allowedAreas.has(area)) {
531
+ report(row, rules.unknownAreaMessage(area), [...templatePath, rowIndex]);
532
+ }
533
+ else if (area !== '.' && !visualAreas.has(area)) {
534
+ visualAreas.add(area);
535
+ visualAreaOrder.push(area);
536
+ }
518
537
  }
519
- else if (layoutDefinition && block.gridArea && !templateAreas.has(block.gridArea)) {
520
- report(block.gridArea, `does not match any area defined in the layout template. Available areas: ${[...templateAreas].join(', ') || 'none'}`, [...path, idx, 'gridArea']);
538
+ }
539
+ let hasMissingArea = false;
540
+ if (rules.required) {
541
+ for (const area of rules.required.areas) {
542
+ if (!visualAreas.has(area)) {
543
+ hasMissingArea = true;
544
+ report(template, rules.required.missingAreaMessage(area), templatePath);
545
+ }
521
546
  }
522
547
  }
548
+ if (hasMissingArea) {
549
+ return;
550
+ }
551
+ const expectedAreaOrder = rules.areaOrder.filter((area) => visualAreas.has(area));
552
+ if (visualAreaOrder.some((area, index) => area !== expectedAreaOrder[index])) {
553
+ report(template, rules.orderMessage(expectedAreaOrder), templatePath);
554
+ }
523
555
  }
524
- function validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled) {
525
- if (!layout || !getGridTemplateAreas(layout).has(breadcrumbsGridArea)) {
556
+ function validateBuiltinPagesLayout(layout, report, path) {
557
+ if (!layout) {
526
558
  return;
527
559
  }
528
- if (!breadcrumbsEnabled) {
529
- for (const [deviceName, deviceDefinition] of Object.entries(layout)) {
530
- const template = deviceDefinition?.layout?.template;
531
- if (template?.some((row) => row.split(' ').includes(breadcrumbsGridArea))) {
532
- report(template, `the '${breadcrumbsGridArea}' grid area requires layout.breadcrumbs to be enabled`, [...path, deviceName, 'layout', 'template']);
533
- }
560
+ validateResponsiveGridLayoutDefinition(layout, report, path);
561
+ for (const [deviceName, deviceDefinition] of Object.entries(layout)) {
562
+ const deviceLayout = deviceDefinition?.layout;
563
+ if (!deviceLayout?.template) {
564
+ continue;
565
+ }
566
+ validateGridTemplateAreaOrder(deviceLayout.template, {
567
+ areaOrder: builtinPagesGridAreaOrder,
568
+ unknownAreaMessage: (area) => `unknown built-in page grid area '${area}'`,
569
+ orderMessage: (expectedAreaOrder) => `built-in page grid areas must follow the accessible order: ${expectedAreaOrder.join(', ')}`,
570
+ }, report, [...path, deviceName, 'layout', 'template']);
571
+ }
572
+ // A device without a grid of its own inherits the one of the next smaller device, so the areas a
573
+ // breakpoint renders are not the areas its own template names. Content renders in every state, so
574
+ // every breakpoint needs a slot for it, and an opted in area needs one at every breakpoint too.
575
+ const defaultLayout = getBuiltinPagesDefaultGridLayout(layout);
576
+ const cascaded = getCascadedGridTemplateAreas(layout, defaultLayout);
577
+ for (const area of defaultLayout.template) {
578
+ const missing = gridDeviceOrder.filter((device) => !cascaded[device].has(area));
579
+ if (missing.length) {
580
+ report(layout, `the '${area}' grid area is missing from the grid rendered on ${missing.join(', ')}`, path);
581
+ }
582
+ }
583
+ }
584
+ function validateBlockGridAreas(blocks, layoutDefinition, report, path) {
585
+ const templateAreas = getGridTemplateAreas(layoutDefinition);
586
+ for (const [idx, block] of blocks.entries()) {
587
+ if (layoutDefinition && block.gridArea && !templateAreas.has(block.gridArea)) {
588
+ report(block.gridArea, `does not match any area defined in the layout template. Available areas: ${[...templateAreas].join(', ') || 'none'}`, [...path, idx, 'gridArea']);
534
589
  }
535
- return;
536
590
  }
537
- // A device inherits the template of the next smaller one, so an area defined only for a larger
538
- // device leaves the trail without a slot to sit in below that breakpoint.
591
+ }
592
+ // A device inherits the template of the next smaller one, so an area defined only for a larger
593
+ // device leaves the element without a slot to sit in below that breakpoint.
594
+ function validateGridAreaCascade(layout, area, report, path) {
539
595
  const cascaded = getCascadedGridTemplateAreas(layout);
540
- const missing = gridDeviceOrder.filter((device) => !cascaded[device].has(breadcrumbsGridArea));
596
+ const missing = gridDeviceOrder.filter((device) => !cascaded[device].has(area));
541
597
  if (missing.length) {
542
- report(layout, `the '${breadcrumbsGridArea}' grid area is missing from the grid rendered on ${missing.join(', ')}`, path);
598
+ report(layout, `the '${area}' grid area is missing from the grid rendered on ${missing.join(', ')}`, path);
543
599
  }
544
600
  }
545
- function validateConsistentBreadcrumbsGridArea(subPages, report) {
546
- // The trail belongs to the page, so it may not appear on one sub page and be missing from the
547
- // next. Defining the area consistently also lets the app runtime tell from the page definition
548
- // alone whether a page renders the trail from its grid or below the title bar.
549
- const declaresArea = ([subPage]) => getGridTemplateAreas(subPage.layout).has(breadcrumbsGridArea);
550
- const declaring = subPages.filter(declaresArea);
551
- if (!declaring.length || declaring.length === subPages.length) {
601
+ function validateReservedGridAreas(layout, report, path, breadcrumbsEnabled) {
602
+ const areas = getGridTemplateAreas(layout);
603
+ if (!layout || !areas.size) {
552
604
  return;
553
605
  }
554
- for (const entry of subPages) {
555
- if (!declaresArea(entry)) {
556
- report(entry[0], `the '${breadcrumbsGridArea}' grid area must be defined by every sub page of this page or by none`, entry[1]);
606
+ if (areas.has(breadcrumbsGridArea)) {
607
+ if (breadcrumbsEnabled) {
608
+ validateGridAreaCascade(layout, breadcrumbsGridArea, report, path);
609
+ }
610
+ else {
611
+ for (const [deviceName, deviceDefinition] of Object.entries(layout)) {
612
+ const template = deviceDefinition?.layout?.template;
613
+ if (template?.some((row) => row.split(' ').includes(breadcrumbsGridArea))) {
614
+ report(template, `the '${breadcrumbsGridArea}' grid area requires layout.breadcrumbs to be enabled`, [...path, deviceName, 'layout', 'template']);
615
+ }
616
+ }
617
+ }
618
+ }
619
+ // The banner and the bottom navigation render nothing when the app has none, so naming their
620
+ // area only has to give every device a slot for it.
621
+ for (const area of [resendBannerGridArea, bottomNavigationGridArea]) {
622
+ if (areas.has(area)) {
623
+ validateGridAreaCascade(layout, area, report, path);
624
+ }
625
+ }
626
+ }
627
+ function validateConsistentGridAreas(subPages, report) {
628
+ // A reserved area belongs to the page, so it may not appear on one sub page and be missing from
629
+ // the next. Defining the area consistently also lets the app runtime tell from the page definition
630
+ // alone whether a page renders the element from its grid or in its default position.
631
+ for (const area of [breadcrumbsGridArea, resendBannerGridArea, bottomNavigationGridArea]) {
632
+ const declaresArea = ([subPage]) => getGridTemplateAreas(subPage.layout).has(area);
633
+ const declaring = subPages.filter(declaresArea);
634
+ if (!declaring.length || declaring.length === subPages.length) {
635
+ continue;
636
+ }
637
+ for (const entry of subPages) {
638
+ if (!declaresArea(entry)) {
639
+ report(entry[0], `the '${area}' grid area must be defined by every sub page of this page or by none`, entry[1]);
640
+ }
557
641
  }
558
642
  }
559
643
  }
560
644
  function validateSubPageLayout(layout, blocks, report, path, breadcrumbsEnabled) {
561
645
  validateResponsiveGridLayoutDefinition(layout, report, path);
562
- validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled);
646
+ validateReservedGridAreas(layout, report, path, breadcrumbsEnabled);
563
647
  if (blocks) {
564
648
  validateBlockGridAreas(blocks, layout, report, [...path, 'blocks']);
565
649
  }
@@ -584,42 +668,23 @@ function validateGridLayout(definition, report) {
584
668
  report(navbarLayout, 'cannot be combined with stackedHeader', ['layout', 'navbar']);
585
669
  }
586
670
  const navbarAreaOrder = ['logo', 'name', 'navigation', 'controls'];
587
- const allowedAreas = new Set(['.', ...navbarAreaOrder]);
588
671
  const requiredAreas = definition.layout?.logo?.position === 'navbar' ? navbarAreaOrder : navbarAreaOrder.slice(1);
589
672
  for (const [deviceName, deviceDefinition] of Object.entries(navbarLayout)) {
590
673
  if (!deviceDefinition?.layout?.template) {
591
674
  continue;
592
675
  }
593
- const { template } = deviceDefinition.layout;
594
- const templatePath = ['layout', 'navbar', deviceName, 'layout', 'template'];
595
- const visualAreaOrder = [];
596
- const visualAreas = new Set();
597
- for (const [rowIndex, row] of deviceDefinition.layout.template.entries()) {
598
- for (const area of row.split(' ')) {
599
- if (!allowedAreas.has(area)) {
600
- report(row, `unknown navbar grid area '${area}'`, [...templatePath, rowIndex]);
601
- }
602
- else if (area !== '.' && !visualAreas.has(area)) {
603
- visualAreas.add(area);
604
- visualAreaOrder.push(area);
605
- }
606
- }
607
- }
608
- let hasMissingArea = false;
609
- for (const area of requiredAreas) {
610
- if (!visualAreas.has(area)) {
611
- hasMissingArea = true;
612
- report(template, `navbar grid is missing required area '${area}'`, templatePath);
613
- }
614
- }
615
- if (!hasMissingArea) {
616
- const expectedAreaOrder = navbarAreaOrder.filter((area) => visualAreas.has(area));
617
- if (visualAreaOrder.some((area, index) => area !== expectedAreaOrder[index])) {
618
- report(template, `navbar grid areas must follow the accessible order: ${expectedAreaOrder.join(', ')}`, templatePath);
619
- }
620
- }
676
+ validateGridTemplateAreaOrder(deviceDefinition.layout.template, {
677
+ areaOrder: navbarAreaOrder,
678
+ required: {
679
+ areas: requiredAreas,
680
+ missingAreaMessage: (area) => `navbar grid is missing required area '${area}'`,
681
+ },
682
+ unknownAreaMessage: (area) => `unknown navbar grid area '${area}'`,
683
+ orderMessage: (expectedAreaOrder) => `navbar grid areas must follow the accessible order: ${expectedAreaOrder.join(', ')}`,
684
+ }, report, ['layout', 'navbar', deviceName, 'layout', 'template']);
621
685
  }
622
686
  }
687
+ validateBuiltinPagesLayout(definition.layout?.builtinPages, report, ['layout', 'builtinPages']);
623
688
  const breadcrumbsEnabled = definition.layout?.breadcrumbs === true;
624
689
  iterApp(definition, {
625
690
  onPage(page, path) {
@@ -640,7 +705,7 @@ function validateGridLayout(definition, report) {
640
705
  for (const [tab, tabPath] of tabs) {
641
706
  validateSubPageLayout(tab.layout, tab.blocks, report, tabPath, breadcrumbsEnabled);
642
707
  }
643
- validateConsistentBreadcrumbsGridArea(tabs, report);
708
+ validateConsistentGridAreas(tabs, report);
644
709
  return;
645
710
  }
646
711
  // Flow page
@@ -653,7 +718,7 @@ function validateGridLayout(definition, report) {
653
718
  for (const [step, stepPath] of steps) {
654
719
  validateSubPageLayout(step.layout, step.blocks, report, stepPath, breadcrumbsEnabled);
655
720
  }
656
- validateConsistentBreadcrumbsGridArea(steps, report);
721
+ validateConsistentGridAreas(steps, report);
657
722
  return;
658
723
  }
659
724
  // Loop page
@@ -667,7 +732,7 @@ function validateGridLayout(definition, report) {
667
732
  for (const [subPage, subPagePath] of subPages) {
668
733
  validateSubPageLayout(subPage.layout, subPage.blocks, report, subPagePath, breadcrumbsEnabled);
669
734
  }
670
- validateConsistentBreadcrumbsGridArea(subPages, report);
735
+ validateConsistentGridAreas(subPages, report);
671
736
  }
672
737
  },
673
738
  });
@@ -1243,13 +1308,36 @@ function validateIcons({ icons }, report) {
1243
1308
  if (!icons) {
1244
1309
  return;
1245
1310
  }
1246
- for (const key of Object.keys(icons)) {
1311
+ // Each overridden icon name, mapped to the key of the entry listing it.
1312
+ const overridden = new Map();
1313
+ for (const [key, entry] of Object.entries(icons)) {
1247
1314
  if (!isValidIconName(key)) {
1248
1315
  report(key, 'is not a valid icon key; use lower case letters, digits, and single hyphens', [
1249
1316
  'icons',
1250
1317
  key,
1251
1318
  ]);
1252
1319
  }
1320
+ // The schema reports entries and override lists of the wrong shape.
1321
+ if (!Array.isArray(entry?.overrides)) {
1322
+ continue;
1323
+ }
1324
+ for (const [index, name] of entry.overrides.entries()) {
1325
+ if (typeof name !== 'string') {
1326
+ continue;
1327
+ }
1328
+ const earlier = overridden.get(name);
1329
+ if (earlier === undefined) {
1330
+ overridden.set(name, key);
1331
+ }
1332
+ else if (earlier !== key) {
1333
+ report(name, `is already overridden by icons.${earlier}`, [
1334
+ 'icons',
1335
+ key,
1336
+ 'overrides',
1337
+ index,
1338
+ ]);
1339
+ }
1340
+ }
1253
1341
  }
1254
1342
  }
1255
1343
  /**