@appsemble/lang-sdk 0.37.6 → 0.38.1

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/iterApp.js CHANGED
@@ -121,8 +121,13 @@ export function iterPage(page, callbacks, prefix = []) {
121
121
  if (result) {
122
122
  return true;
123
123
  }
124
- const { foreach } = page;
125
- return ['steps.first', 'steps', 'steps.last'].some((suffix) => iterBlockList(foreach?.blocks ?? [], callbacks, [...prefix, suffix, 'blocks']));
124
+ const { end, foreach, start } = page;
125
+ const subPages = [
126
+ ['steps.first', start ?? foreach],
127
+ ['steps', foreach],
128
+ ['steps.last', end ?? foreach],
129
+ ];
130
+ return subPages.some(([suffix, subPage]) => iterBlockList(subPage?.blocks ?? [], callbacks, [...prefix, suffix, 'blocks']));
126
131
  }
127
132
  if (page.type === 'container') {
128
133
  let result = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/lang-sdk",
3
- "version": "0.37.6",
3
+ "version": "0.38.1",
4
4
  "description": "Language SDK for Appsemble",
5
5
  "keywords": [
6
6
  "app",
@@ -58,7 +58,7 @@
58
58
  "devDependencies": {
59
59
  "@types/language-tags": "1.0.4",
60
60
  "@types/lcm": "0.0.2",
61
- "vitest": "4.1.10"
61
+ "vitest": "4.1.11"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=24"
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',
@@ -104,6 +113,20 @@ Only applies when \`navigation\` is set to \`top\`.
104
113
  type: 'boolean',
105
114
  default: false,
106
115
  },
116
+ navbar: {
117
+ $ref: '#/components/schemas/NavbarLayoutDefinition',
118
+ description: `Responsive grid layout for the top navigation header.
119
+
120
+ Only applies when \`navigation\` is set to \`top\` and cannot be combined with \`stackedHeader\`.
121
+ `,
122
+ },
123
+ breakpoints: {
124
+ $ref: '#/components/schemas/GridBreakpointsDefinition',
125
+ description: `Minimum viewport widths in pixels at which the tablet and desktop grid layouts apply.
126
+
127
+ Applies to every responsive grid layout in the app, including pages and the navbar.
128
+ `,
129
+ },
107
130
  hideGroupDropdown: {
108
131
  description: `Whether to hide the group dropdown.
109
132
 
@@ -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
  };
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const GridBreakpointsDefinition: OpenAPIV3.NonArraySchemaObject;
@@ -0,0 +1,22 @@
1
+ export const GridBreakpointsDefinition = {
2
+ type: 'object',
3
+ description: `Minimum viewport widths in pixels at which the tablet and desktop grid layouts apply.
4
+
5
+ The mobile layout always applies from a width of 0. Each breakpoint must be smaller than the next.
6
+ `,
7
+ additionalProperties: false,
8
+ minProperties: 1,
9
+ properties: {
10
+ tablet: {
11
+ type: 'integer',
12
+ minimum: 0,
13
+ description: 'Minimum viewport width in pixels at which the tablet grid layout applies.',
14
+ },
15
+ desktop: {
16
+ type: 'integer',
17
+ minimum: 0,
18
+ description: 'Minimum viewport width in pixels at which the desktop grid layout applies.',
19
+ },
20
+ },
21
+ };
22
+ //# sourceMappingURL=GridBreakpointsDefinition.js.map
@@ -5,9 +5,9 @@ export const GridSpacingDefinition = {
5
5
  properties: {
6
6
  unit: {
7
7
  type: 'string',
8
- description: 'CSS unit for spacing calculations (e.g., "1rem", "8px")',
8
+ description: 'CSS unit or custom property for spacing calculations (e.g., "1rem", "8px", "var(--grid-unit)")',
9
9
  default: '1rem',
10
- pattern: '^-?(\\d+\\.?\\d*|\\.\\d+)(rem|em|px|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc)$',
10
+ pattern: '^(?:-?(\\d+\\.?\\d*|\\.\\d+)(rem|em|px|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc)|var\\(--[A-Za-z_][A-Za-z0-9_-]*\\))$',
11
11
  },
12
12
  gap: {
13
13
  type: 'number',
@@ -27,6 +27,14 @@ By default the flow page retains it's data after navigating once. Set to false t
27
27
  `,
28
28
  default: true,
29
29
  },
30
+ start: {
31
+ $ref: '#/components/schemas/SubPage',
32
+ description: 'A page to display before the generated loop pages.',
33
+ },
34
+ end: {
35
+ $ref: '#/components/schemas/SubPage',
36
+ description: 'A page to display after the generated loop pages.',
37
+ },
30
38
  },
31
39
  });
32
40
  //# sourceMappingURL=LoopPageDefinition.js.map
@@ -0,0 +1 @@
1
+ export declare const NavbarDeviceGridLayoutDefinition: import("openapi-types").OpenAPIV3.SchemaObject;
@@ -0,0 +1,9 @@
1
+ import { DeviceGridLayoutDefinition } from './DeviceGridLayoutDefinition.js';
2
+ import { extendJSONSchema } from './utils/extendJSONSchema.js';
3
+ export const NavbarDeviceGridLayoutDefinition = extendJSONSchema(DeviceGridLayoutDefinition, {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ required: ['layout'],
7
+ description: 'Grid layout definition for a navbar breakpoint',
8
+ });
9
+ //# sourceMappingURL=NavbarDeviceGridLayoutDefinition.js.map
@@ -0,0 +1 @@
1
+ export declare const NavbarLayoutDefinition: import("openapi-types").OpenAPIV3.SchemaObject;
@@ -0,0 +1,24 @@
1
+ import { ResponsiveGridLayoutDefinition } from './ResponsiveGridLayoutDefinition.js';
2
+ import { extendJSONSchema } from './utils/extendJSONSchema.js';
3
+ export const NavbarLayoutDefinition = extendJSONSchema(ResponsiveGridLayoutDefinition, {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ minProperties: 1,
7
+ description: `Responsive grid layout for the top navigation header.
8
+
9
+ The available template areas are logo, name, navigation, and controls.
10
+ Each rendered area must be included and their first visual occurrence must follow that order.
11
+ `,
12
+ properties: {
13
+ desktop: {
14
+ $ref: '#/components/schemas/NavbarDeviceGridLayoutDefinition',
15
+ },
16
+ mobile: {
17
+ $ref: '#/components/schemas/NavbarDeviceGridLayoutDefinition',
18
+ },
19
+ tablet: {
20
+ $ref: '#/components/schemas/NavbarDeviceGridLayoutDefinition',
21
+ },
22
+ },
23
+ });
24
+ //# sourceMappingURL=NavbarLayoutDefinition.js.map
@@ -1,2 +1 @@
1
- import { type OpenAPIV3 } from 'openapi-types';
2
- export declare const PageLayoutDefinition: OpenAPIV3.NonArraySchemaObject;
1
+ export declare const PageLayoutDefinition: import("openapi-types").OpenAPIV3.SchemaObject;
@@ -1,18 +1,14 @@
1
- export const PageLayoutDefinition = {
1
+ import { ResponsiveGridLayoutDefinition } from './ResponsiveGridLayoutDefinition.js';
2
+ import { extendJSONSchema } from './utils/extendJSONSchema.js';
3
+ export const PageLayoutDefinition = extendJSONSchema(ResponsiveGridLayoutDefinition, {
2
4
  type: 'object',
3
- description: 'Grid layout of the page, define as an anchor to re-use across multiple pages',
4
5
  additionalProperties: false,
5
6
  minProperties: 1,
6
- properties: {
7
- mobile: {
8
- $ref: '#/components/schemas/DeviceGridLayoutDefinition',
9
- },
10
- tablet: {
11
- $ref: '#/components/schemas/DeviceGridLayoutDefinition',
12
- },
13
- desktop: {
14
- $ref: '#/components/schemas/DeviceGridLayoutDefinition',
15
- },
16
- },
17
- };
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
+ `,
13
+ });
18
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
@@ -100,10 +100,10 @@ name of the resource and how it should behave.
100
100
  },
101
101
  enforceOrderingGroupByFields: {
102
102
  type: 'array',
103
- pattern: '^[a-zA-Z0-9]*$',
104
103
  description: 'Group resources by fields when handling resource positions',
105
104
  items: {
106
105
  type: 'string',
106
+ pattern: '^[a-zA-Z0-9]*$',
107
107
  },
108
108
  minItems: 1,
109
109
  },
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const ResponsiveGridLayoutDefinition: OpenAPIV3.NonArraySchemaObject;
@@ -0,0 +1,18 @@
1
+ export const ResponsiveGridLayoutDefinition = {
2
+ type: 'object',
3
+ description: 'Responsive grid layout',
4
+ additionalProperties: false,
5
+ minProperties: 1,
6
+ properties: {
7
+ mobile: {
8
+ $ref: '#/components/schemas/DeviceGridLayoutDefinition',
9
+ },
10
+ tablet: {
11
+ $ref: '#/components/schemas/DeviceGridLayoutDefinition',
12
+ },
13
+ desktop: {
14
+ $ref: '#/components/schemas/DeviceGridLayoutDefinition',
15
+ },
16
+ },
17
+ };
18
+ //# sourceMappingURL=ResponsiveGridLayoutDefinition.js.map
@@ -124,6 +124,11 @@ export * from './TabsPageDefinition.js';
124
124
  export * from './Theme.js';
125
125
  export * from './WebhookDefinition.js';
126
126
  export * from './DeviceGridLayoutDefinition.js';
127
+ export * from './GridBreakpointsDefinition.js';
127
128
  export * from './GridLayoutDefinition.js';
128
129
  export * from './GridSpacingDefinition.js';
130
+ export * from './ResponsiveGridLayoutDefinition.js';
131
+ export * from './NavbarDeviceGridLayoutDefinition.js';
132
+ export * from './NavbarLayoutDefinition.js';
129
133
  export * from './PageLayoutDefinition.js';
134
+ export * from './PageParentDefinition.js';
package/schemas/index.js CHANGED
@@ -124,7 +124,12 @@ export * from './TabsPageDefinition.js';
124
124
  export * from './Theme.js';
125
125
  export * from './WebhookDefinition.js';
126
126
  export * from './DeviceGridLayoutDefinition.js';
127
+ export * from './GridBreakpointsDefinition.js';
127
128
  export * from './GridLayoutDefinition.js';
128
129
  export * from './GridSpacingDefinition.js';
130
+ export * from './ResponsiveGridLayoutDefinition.js';
131
+ export * from './NavbarDeviceGridLayoutDefinition.js';
132
+ export * from './NavbarLayoutDefinition.js';
129
133
  export * from './PageLayoutDefinition.js';
134
+ export * from './PageParentDefinition.js';
130
135
  //# sourceMappingURL=index.js.map
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 PageLayoutDefinition } from './gridLayout.js';
12
+ import { 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
  export interface AppDefinition {
@@ -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
  */
@@ -118,6 +122,24 @@ export interface AppDefinition {
118
122
  * Only applies when `navigation` is set to `top`.
119
123
  */
120
124
  stackedHeader?: boolean;
125
+ /**
126
+ * Responsive grid layout for the top navigation header.
127
+ *
128
+ * Only applies when `navigation` is set to `top`. This cannot be combined with
129
+ * `stackedHeader`.
130
+ *
131
+ * The available template areas are logo, name, navigation, and controls.
132
+ * Each rendered area must be included and their first visual occurrence must follow that order.
133
+ */
134
+ navbar?: NavbarLayoutDefinition;
135
+ /**
136
+ * Minimum viewport widths in pixels at which the tablet and desktop grid layouts apply.
137
+ *
138
+ * Applies to every responsive grid layout in the app, including pages and the navbar. The
139
+ * mobile layout always applies from a width of 0. Each breakpoint must be smaller than the
140
+ * next.
141
+ */
142
+ breakpoints?: GridBreakpointsDefinition;
121
143
  /**
122
144
  * Whether to hide the group dropdown.
123
145
  *
@@ -181,6 +203,13 @@ export interface AppDefinition {
181
203
  */
182
204
  registry?: string;
183
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
+ };
184
213
  /**
185
214
  * This describes what a page will look like in the app.
186
215
  */
@@ -215,6 +244,14 @@ export interface BasePageDefinition {
215
244
  * top navigation is rendered as side navigation.
216
245
  */
217
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[];
218
255
  /**
219
256
  * A list of roles that may view the page.
220
257
  */
@@ -316,6 +353,14 @@ export interface LoopPageDefinition extends BasePageDefinition {
316
353
  * @default true
317
354
  */
318
355
  retainFlowData?: boolean;
356
+ /**
357
+ * The page to display before the generated loop pages.
358
+ */
359
+ start?: SubPageDefinition;
360
+ /**
361
+ * The page to display after the generated loop pages.
362
+ */
363
+ end?: SubPageDefinition;
319
364
  }
320
365
  export interface AlternateTabsDefinition {
321
366
  foreach: SubPageDefinition;
@@ -8,12 +8,26 @@ export interface GridLayoutSpacingDefinition {
8
8
  padding: number;
9
9
  gap: number;
10
10
  }
11
+ export interface GridBreakpointsDefinition {
12
+ tablet?: number;
13
+ desktop?: number;
14
+ }
11
15
  export interface DeviceGridLayoutDefinition {
16
+ layout?: GridLayoutDefinition;
17
+ spacing?: GridLayoutSpacingDefinition;
18
+ }
19
+ export interface ResponsiveGridLayoutDefinition {
20
+ desktop?: DeviceGridLayoutDefinition;
21
+ mobile?: DeviceGridLayoutDefinition;
22
+ tablet?: DeviceGridLayoutDefinition;
23
+ }
24
+ export interface NavbarDeviceGridLayoutDefinition extends DeviceGridLayoutDefinition {
12
25
  layout: GridLayoutDefinition;
13
- spacing: GridLayoutSpacingDefinition;
14
26
  }
15
- export type PageLayoutDefinition = RequireAtLeastOne<{
16
- mobile: DeviceGridLayoutDefinition;
17
- tablet: DeviceGridLayoutDefinition;
18
- desktop: DeviceGridLayoutDefinition;
19
- }>;
27
+ export interface ResponsiveNavbarGridLayoutDefinition {
28
+ desktop?: NavbarDeviceGridLayoutDefinition;
29
+ mobile?: NavbarDeviceGridLayoutDefinition;
30
+ tablet?: NavbarDeviceGridLayoutDefinition;
31
+ }
32
+ export type NavbarLayoutDefinition = RequireAtLeastOne<ResponsiveNavbarGridLayoutDefinition>;
33
+ export type PageLayoutDefinition = RequireAtLeastOne<ResponsiveGridLayoutDefinition>;
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) {
@@ -437,7 +484,7 @@ function validateTemplateAreasAreRectangular(template, report, path) {
437
484
  }
438
485
  }
439
486
  }
440
- function validatePageLayoutDefinition(layoutDefinition, report, path) {
487
+ function validateResponsiveGridLayoutDefinition(layoutDefinition, report, path) {
441
488
  if (!layoutDefinition) {
442
489
  return;
443
490
  }
@@ -445,10 +492,16 @@ function validatePageLayoutDefinition(layoutDefinition, report, path) {
445
492
  if (!deviceDefinition?.layout?.template || !deviceDefinition?.layout?.columns) {
446
493
  continue;
447
494
  }
448
- if (deviceDefinition.layout.template.some((row) => row.split(' ').length !== deviceDefinition.layout.columns)) {
449
- report(deviceDefinition.layout.template, 'template needs to be the same length as number of columns', [...path, deviceName, 'layout', 'template']);
495
+ const { layout } = deviceDefinition;
496
+ if (layout.template.some((row) => row.split(' ').length !== layout.columns)) {
497
+ report(layout.template, 'template needs to be the same length as number of columns', [
498
+ ...path,
499
+ deviceName,
500
+ 'layout',
501
+ 'template',
502
+ ]);
450
503
  }
451
- validateTemplateAreasAreRectangular(deviceDefinition.layout.template, report, [
504
+ validateTemplateAreasAreRectangular(layout.template, report, [
452
505
  ...path,
453
506
  deviceName,
454
507
  'layout',
@@ -457,56 +510,163 @@ function validatePageLayoutDefinition(layoutDefinition, report, path) {
457
510
  }
458
511
  }
459
512
  function validateBlockGridAreas(blocks, layoutDefinition, report, path) {
460
- const templateAreas = getTemplateAreas(layoutDefinition);
513
+ const templateAreas = getGridTemplateAreas(layoutDefinition);
461
514
  for (const [idx, block] of blocks.entries()) {
462
- 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)) {
463
519
  report(block.gridArea, `does not match any area defined in the layout template. Available areas: ${[...templateAreas].join(', ') || 'none'}`, [...path, idx, 'gridArea']);
464
520
  }
465
521
  }
466
522
  }
467
- function validateSubPageLayout(layout, blocks, report, path) {
468
- validatePageLayoutDefinition(layout, report, path);
469
- if (layout && blocks) {
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) {
560
+ validateResponsiveGridLayoutDefinition(layout, report, path);
561
+ validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled);
562
+ if (blocks) {
470
563
  validateBlockGridAreas(blocks, layout, report, [...path, 'blocks']);
471
564
  }
472
565
  }
473
566
  function validateGridLayout(definition, report) {
567
+ const breakpoints = definition.layout?.breakpoints;
568
+ if (breakpoints?.tablet != null &&
569
+ breakpoints?.desktop != null &&
570
+ breakpoints.tablet >= breakpoints.desktop) {
571
+ report(breakpoints, 'tablet breakpoint must be smaller than the desktop breakpoint', [
572
+ 'layout',
573
+ 'breakpoints',
574
+ ]);
575
+ }
576
+ const navbarLayout = definition.layout?.navbar;
577
+ validateResponsiveGridLayoutDefinition(navbarLayout, report, ['layout', 'navbar']);
578
+ if (navbarLayout) {
579
+ if (definition.layout?.navigation !== 'top') {
580
+ report(navbarLayout, 'only applies when navigation is set to top', ['layout', 'navbar']);
581
+ }
582
+ if (definition.layout?.stackedHeader) {
583
+ report(navbarLayout, 'cannot be combined with stackedHeader', ['layout', 'navbar']);
584
+ }
585
+ const navbarAreaOrder = ['logo', 'name', 'navigation', 'controls'];
586
+ const allowedAreas = new Set(['.', ...navbarAreaOrder]);
587
+ const requiredAreas = definition.layout?.logo?.position === 'navbar' ? navbarAreaOrder : navbarAreaOrder.slice(1);
588
+ for (const [deviceName, deviceDefinition] of Object.entries(navbarLayout)) {
589
+ if (!deviceDefinition?.layout?.template) {
590
+ continue;
591
+ }
592
+ const { template } = deviceDefinition.layout;
593
+ const templatePath = ['layout', 'navbar', deviceName, 'layout', 'template'];
594
+ const visualAreaOrder = [];
595
+ const visualAreas = new Set();
596
+ for (const [rowIndex, row] of deviceDefinition.layout.template.entries()) {
597
+ for (const area of row.split(' ')) {
598
+ if (!allowedAreas.has(area)) {
599
+ report(row, `unknown navbar grid area '${area}'`, [...templatePath, rowIndex]);
600
+ }
601
+ else if (area !== '.' && !visualAreas.has(area)) {
602
+ visualAreas.add(area);
603
+ visualAreaOrder.push(area);
604
+ }
605
+ }
606
+ }
607
+ let hasMissingArea = false;
608
+ for (const area of requiredAreas) {
609
+ if (!visualAreas.has(area)) {
610
+ hasMissingArea = true;
611
+ report(template, `navbar grid is missing required area '${area}'`, templatePath);
612
+ }
613
+ }
614
+ if (!hasMissingArea) {
615
+ const expectedAreaOrder = navbarAreaOrder.filter((area) => visualAreas.has(area));
616
+ if (visualAreaOrder.some((area, index) => area !== expectedAreaOrder[index])) {
617
+ report(template, `navbar grid areas must follow the accessible order: ${expectedAreaOrder.join(', ')}`, templatePath);
618
+ }
619
+ }
620
+ }
621
+ }
622
+ const breadcrumbsEnabled = definition.layout?.breadcrumbs === true;
474
623
  iterApp(definition, {
475
624
  onPage(page, path) {
476
625
  // Basic page
477
626
  if (page.type === 'page' || page.type === undefined) {
478
627
  const basicPage = page;
479
- validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path);
628
+ validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path, breadcrumbsEnabled);
480
629
  return;
481
630
  }
482
631
  // Tabs page
483
632
  if (page.type === 'tabs') {
484
633
  const tabsPage = page;
485
- if (tabsPage.tabs) {
486
- for (const [tabId, tab] of tabsPage.tabs.entries()) {
487
- validateSubPageLayout(tab.layout, tab.blocks, report, [...path, 'tabs', tabId]);
488
- }
489
- }
490
- else if (tabsPage.definition?.foreach) {
491
- validateSubPageLayout(tabsPage.definition.foreach.layout, tabsPage.definition.foreach.blocks, report, [...path, 'definition', 'foreach']);
492
- }
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);
493
643
  return;
494
644
  }
495
645
  // Flow page
496
646
  if (page.type === 'flow') {
497
647
  const flowPage = page;
498
- for (const [stepId, step] of flowPage.steps.entries()) {
499
- 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);
500
654
  }
655
+ validateConsistentBreadcrumbsGridArea(steps, report);
501
656
  return;
502
657
  }
503
658
  // Loop page
504
659
  if (page.type === 'loop') {
505
660
  const loopPage = page;
506
- validateSubPageLayout(loopPage.foreach.layout, loopPage.foreach.blocks, report, [
507
- ...path,
508
- 'foreach',
509
- ]);
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);
668
+ }
669
+ validateConsistentBreadcrumbsGridArea(subPages, report);
510
670
  }
511
671
  },
512
672
  });
@@ -999,6 +1159,13 @@ function validateSecurity(definition, report) {
999
1159
  }
1000
1160
  }
1001
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
+ }
1002
1169
  const { hideGroupDropdown } = definition.layout ?? {};
1003
1170
  if (Array.isArray(hideGroupDropdown)) {
1004
1171
  for (const [index, role] of hideGroupDropdown.entries()) {
@@ -1503,6 +1670,7 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
1503
1670
  validateLanguage(clonedDefinition, report);
1504
1671
  validateResourceReferences(clonedDefinition, report);
1505
1672
  validateMembersSchema(clonedDefinition, report);
1673
+ validatePageParents(clonedDefinition, report);
1506
1674
  validatePhoneNumberDefinition(clonedDefinition, report);
1507
1675
  validateResourceSchemas(clonedDefinition, report);
1508
1676
  validateSecurity(clonedDefinition, report);