@appsemble/lang-sdk 0.40.9 → 0.40.11-test.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,12 @@ export declare const partialNormalized: RegExp;
18
18
  * A pattern for exactly matching a lower case hyphen separated string.
19
19
  */
20
20
  export declare const normalized: RegExp;
21
+ /**
22
+ * A pattern for validating a theme variable (custom CSS property) value.
23
+ *
24
+ * Any single-line CSS value that cannot escape its declaration block is allowed.
25
+ */
26
+ export declare const themeVariableValue: RegExp;
21
27
  /**
22
28
  * A pattern for matching the block name pattern of @organization/project.
23
29
  */
@@ -19,6 +19,12 @@ export const partialNormalized = /([0-9a-z](?:(?!.*--)[-0-9a-z]*[0-9a-z])?)/;
19
19
  * A pattern for exactly matching a lower case hyphen separated string.
20
20
  */
21
21
  export const normalized = new RegExp(`^${partialNormalized.source}$`);
22
+ /**
23
+ * A pattern for validating a theme variable (custom CSS property) value.
24
+ *
25
+ * Any single-line CSS value that cannot escape its declaration block is allowed.
26
+ */
27
+ export const themeVariableValue = /^[^\n\r;{}]+$/;
22
28
  /**
23
29
  * A pattern for matching the block name pattern of @organization/project.
24
30
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/lang-sdk",
3
- "version": "0.40.9",
3
+ "version": "0.40.11-test.0",
4
4
  "description": "Language SDK for Appsemble",
5
5
  "keywords": [
6
6
  "app",
@@ -27,7 +27,27 @@ This will be displayed on the app store.
27
27
  description: 'Properties related to the layout of the app.',
28
28
  },
29
29
  notifications: {
30
- enum: ['login', 'opt-in', 'startup'],
30
+ oneOf: [
31
+ { enum: ['login', 'opt-in', 'startup'] },
32
+ {
33
+ type: 'object',
34
+ required: ['roles'],
35
+ additionalProperties: false,
36
+ properties: {
37
+ default: {
38
+ enum: ['login', 'none', 'opt-in', 'startup'],
39
+ description: `The strategy for roles that aren’t listed in \`roles\`, and for users without
40
+ a role. Defaults to \`none\`.`,
41
+ },
42
+ roles: {
43
+ type: 'object',
44
+ minProperties: 1,
45
+ description: 'The strategy per role.',
46
+ additionalProperties: { enum: ['login', 'none', 'opt-in'] },
47
+ },
48
+ },
49
+ },
50
+ ],
31
51
  description: `The strategy to use for apps to subscribe to push notifications.
32
52
 
33
53
  If specified, push notifications can be sent to subscribed users via the _Notifications_ tab in the
@@ -37,6 +57,10 @@ to \`startup\` will cause Appsemble to immediately request for the permission up
37
57
  If this is set to \`login\`, the app will request permissions for push notification once the user
38
58
  logs in.
39
59
 
60
+ To use a different strategy per role, set \`roles\` to a map of role names to \`login\`, \`opt-in\`, or
61
+ \`none\`. Roles that aren’t listed use \`default\`. A member with several roles gets the most eager
62
+ strategy of those roles. Only roles assigned to the member count, not roles they inherit.
63
+
40
64
  > **Note**: Setting \`notifications\` to \`startup\` is not recommended, due to its invasive nature.
41
65
  `,
42
66
  },
@@ -1,4 +1,4 @@
1
- import { partialNormalized, semver } from '../constants/index.js';
1
+ import { normalized, partialNormalized, semver } from '../constants/index.js';
2
2
  export const BlockDefinition = {
3
3
  type: 'object',
4
4
  description: 'A block that is displayed on a page.',
@@ -29,6 +29,19 @@ Some examples:
29
29
 
30
30
  Since blocks may change over time, a version must be specified in order to ensure that the block
31
31
  will remain the same until it is manually updated.
32
+ `,
33
+ },
34
+ name: {
35
+ type: 'string',
36
+ pattern: normalized.source,
37
+ maxLength: 40,
38
+ description: `A name which identifies this single block instance.
39
+
40
+ The name is rendered as a \`data-block-name\` attribute on the wrapper element of the block, so app
41
+ styles can target one instance instead of every block of the same type. Names must be lower case and
42
+ hyphen separated.
43
+
44
+ For example, a block named \`screen-title\` is targeted with \`[data-block-name='screen-title']\`.
32
45
  `,
33
46
  },
34
47
  layout: {
package/schemas/Theme.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { baseTheme } from '../constants/baseTheme.js';
2
- import { hexColor } from '../constants/patterns.js';
2
+ import { hexColor, normalized, themeVariableValue } from '../constants/patterns.js';
3
3
  export const Theme = {
4
4
  type: 'object',
5
5
  description: 'A generic theme definition.',
@@ -90,6 +90,38 @@ the CSS of the app and its blocks. It falls back to the body font.
90
90
  { $ref: '#/components/schemas/GoogleFontDefinition' },
91
91
  ],
92
92
  },
93
+ variables: {
94
+ type: 'object',
95
+ description: `Custom CSS properties (design tokens) exposed on \`:root\` in the theme CSS.
96
+
97
+ Each entry is emitted as a \`--<name>\` custom property. Names must be lower case and hyphen
98
+ separated. Custom properties inherit into every block's shadow DOM, so tokens defined once are
99
+ usable in core, shared, and block styles.
100
+
101
+ For example:
102
+
103
+ \`\`\`yaml
104
+ theme:
105
+ variables:
106
+ night: '#0e1220'
107
+ font-display: "'Baloo 2', sans-serif"
108
+ \`\`\`
109
+
110
+ These can be referenced in custom CSS as \`var(--night)\` and \`var(--font-display)\`.
111
+ `,
112
+ minProperties: 1,
113
+ maxProperties: 32,
114
+ // The validator supports propertyNames, but the OpenAPI schema object type does not know it.
115
+ propertyNames: {
116
+ pattern: normalized.source,
117
+ maxLength: 40,
118
+ },
119
+ additionalProperties: {
120
+ type: 'string',
121
+ pattern: themeVariableValue.source,
122
+ maxLength: 128,
123
+ },
124
+ },
93
125
  },
94
126
  };
95
127
  //# sourceMappingURL=Theme.js.map
@@ -42,7 +42,7 @@ If \`to\`, \`cc\`, and \`bcc\` all end up being empty, no emails will be sent bu
42
42
 
43
43
  The remapper must resolve to an object containing the following properties:
44
44
 
45
- - \`target\`: The asset ID or link to download contents from to add as an attachment. This is mutually exclusive with \`content\`.
45
+ - \`target\`: The asset ID or link to download contents from to add as an attachment. This is mutually exclusive with \`content\`. The files downloaded from links may total at most 25 MiB per email.
46
46
  - \`content\`: The raw content to include as the file content. This is mutually exclusive with \`target\`.
47
47
  - \`filename\`: The filename to include the attachment as.
48
48
  - \`accept\` If the target is a URL, this will be set as the HTTP \`Accept\` header when downloading the file.
package/types/block.d.ts CHANGED
@@ -48,6 +48,14 @@ export interface BlockDefinition extends ControllerDefinition {
48
48
  * ^\d+\.\d+\.\d+$
49
49
  */
50
50
  version: string;
51
+ /**
52
+ * A name which identifies this single block instance.
53
+ *
54
+ * It is rendered as a `data-block-name` attribute on the wrapper element of the block, so app
55
+ * styles can target one instance instead of every block of the same type. Names must be lower
56
+ * case and hyphen separated.
57
+ */
58
+ name?: string;
51
59
  /**
52
60
  * An optional header to render above the block.
53
61
  */
package/types/core.d.ts CHANGED
@@ -184,9 +184,19 @@ export interface AppDefinition {
184
184
  /**
185
185
  * The strategy to use for apps to subscribe to push notifications.
186
186
  *
187
- * If this is omitted, push notifications can not be sent.
187
+ * Either one strategy for all app members, or a strategy per role. If this is omitted, push
188
+ * notifications can not be sent.
188
189
  */
189
- notifications?: 'login' | 'opt-in' | 'startup';
190
+ notifications?: 'login' | 'opt-in' | 'startup' | {
191
+ /**
192
+ * The strategy for roles that aren't listed in `roles`, and for users without a role.
193
+ */
194
+ default?: 'login' | 'none' | 'opt-in' | 'startup';
195
+ /**
196
+ * The strategy per role.
197
+ */
198
+ roles: Record<AppRole, 'login' | 'none' | 'opt-in'>;
199
+ };
190
200
  /**
191
201
  * The pages of the app.
192
202
  */
package/types/theme.d.ts CHANGED
@@ -51,6 +51,13 @@ export interface Theme {
51
51
  * The name of a font available on Google fonts.
52
52
  */
53
53
  font: FontDefinition;
54
+ /**
55
+ * Custom CSS properties (design tokens) exposed on `:root` in the compiled theme CSS.
56
+ *
57
+ * Each entry is emitted as a `--<name>` custom property. Custom properties inherit into every
58
+ * block's shadow DOM, so tokens defined once are usable in core, shared, and block styles.
59
+ */
60
+ variables?: Record<string, string>;
54
61
  /**
55
62
  * A second font family the app loads, exposed as the `--font-family-display` custom property for
56
63
  * the CSS of the app and its blocks.
package/validation.js CHANGED
@@ -9,7 +9,7 @@ import { findPageByName } from './findPageByName.js';
9
9
  import { isAppLink } from './pageUtils.js';
10
10
  import { bottomNavigationGridArea, breadcrumbsGridArea, builtinPagesGridAreaOrder, getBuiltinPagesDefaultGridLayout, getCascadedGridTemplateAreas, getGridTemplateAreas, gridDeviceOrder, resendBannerGridArea, } from './gridUtils.js';
11
11
  import { isValidIconName, parseIconReference } from './icons.js';
12
- import { iterApp } from './iterApp.js';
12
+ import { iterApp, iterBlockList } from './iterApp.js';
13
13
  import { has } from './miscellaneous.js';
14
14
  import { normalize } from './normalize.js';
15
15
  import { serverActions } from './serverActions.js';
@@ -97,7 +97,7 @@ function validateUniquePageNames(definition, report) {
97
97
  pageNames.set(normalizedPageName, [pagePath]);
98
98
  }
99
99
  if (page.type === 'container') {
100
- checkPages(page.pages, pagePath);
100
+ checkPages(page.pages ?? [], pagePath);
101
101
  }
102
102
  }
103
103
  }
@@ -122,7 +122,7 @@ function iterPageParents(pages, onParent, prefix = ['pages']) {
122
122
  onParent(entry, Array.isArray(page.parent) ? [...pagePath, 'parent', entryIndex] : [...pagePath, 'parent'], page);
123
123
  }
124
124
  if (page.type === 'container') {
125
- iterPageParents(page.pages, onParent, [...pagePath, 'pages']);
125
+ iterPageParents(page.pages ?? [], onParent, [...pagePath, 'pages']);
126
126
  }
127
127
  }
128
128
  }
@@ -167,6 +167,74 @@ function validatePageParents(definition, report) {
167
167
  }
168
168
  });
169
169
  }
170
+ /**
171
+ * Validates the blocks in the app definition to ensure there are no duplicate block names.
172
+ *
173
+ * A block name identifies a single block within the screen it is rendered in. A page renders one
174
+ * of its tabs or steps at a time, so uniqueness is scoped to the block list the block belongs to.
175
+ * The same name may be used on other pages, tabs and steps, which lets an app style the same role
176
+ * everywhere with one rule.
177
+ *
178
+ * @param definition The definition of the app
179
+ * @param report A function used to report a value.
180
+ */
181
+ function validateUniqueBlockNames(definition, report) {
182
+ function checkBlockList(blocks, path) {
183
+ const names = new Set();
184
+ iterBlockList(blocks, {
185
+ onBlock(block, blockPath) {
186
+ if (block.name) {
187
+ if (names.has(block.name)) {
188
+ report(block.name, 'is a duplicate block name', [...blockPath, 'name']);
189
+ }
190
+ else {
191
+ names.add(block.name);
192
+ }
193
+ }
194
+ },
195
+ }, path);
196
+ }
197
+ iterApp(definition, {
198
+ onPage(page, path) {
199
+ // Basic page
200
+ if (page.type === 'page' || page.type === undefined) {
201
+ checkBlockList(page.blocks, [...path, 'blocks']);
202
+ return;
203
+ }
204
+ // Tabs page
205
+ if (page.type === 'tabs') {
206
+ const tabsPage = page;
207
+ if (tabsPage.tabs) {
208
+ for (const [tabId, tab] of tabsPage.tabs.entries()) {
209
+ checkBlockList(tab.blocks, [...path, 'tabs', tabId, 'blocks']);
210
+ }
211
+ }
212
+ else if (tabsPage.definition?.foreach) {
213
+ checkBlockList(tabsPage.definition.foreach.blocks, [
214
+ ...path,
215
+ 'definition',
216
+ 'foreach',
217
+ 'blocks',
218
+ ]);
219
+ }
220
+ return;
221
+ }
222
+ // Flow page
223
+ if (page.type === 'flow' && page.steps) {
224
+ const flowPage = page;
225
+ for (const [stepId, step] of flowPage.steps.entries()) {
226
+ checkBlockList(step.blocks, [...path, 'steps', stepId, 'blocks']);
227
+ }
228
+ return;
229
+ }
230
+ // Loop page
231
+ if (page.type === 'loop') {
232
+ const loopPage = page;
233
+ checkBlockList(loopPage.foreach.blocks, [...path, 'foreach', 'blocks']);
234
+ }
235
+ },
236
+ });
237
+ }
170
238
  function validateMembersSchema(definition, report) {
171
239
  if (!definition.members?.properties) {
172
240
  return;
@@ -703,7 +771,7 @@ function validateGridLayout(definition, report) {
703
771
  // Flow page
704
772
  if (page.type === 'flow') {
705
773
  const flowPage = page;
706
- const steps = flowPage.steps.map((step, stepId) => [
774
+ const steps = (flowPage.steps ?? []).map((step, stepId) => [
707
775
  step,
708
776
  [...path, 'steps', stepId],
709
777
  ]);
@@ -1122,8 +1190,9 @@ function validateSecurity(definition, report) {
1122
1190
  checkRoleExists(role, [...path, 'roles', index], ['$guest', ...predefinedRoles]);
1123
1191
  }
1124
1192
  };
1193
+ const notificationRoles = notifications && typeof notifications === 'object' ? notifications.roles : undefined;
1125
1194
  if (!security) {
1126
- if (notifications === 'login') {
1195
+ if (notifications === 'login' || notificationRoles) {
1127
1196
  report(notifications, 'only works if security is defined', ['notifications']);
1128
1197
  }
1129
1198
  return;
@@ -1224,6 +1293,11 @@ function validateSecurity(definition, report) {
1224
1293
  }
1225
1294
  });
1226
1295
  }
1296
+ if (notificationRoles) {
1297
+ for (const role of Object.keys(notificationRoles)) {
1298
+ checkRoleExists(role, ['notifications', 'roles', role]);
1299
+ }
1300
+ }
1227
1301
  const { hideGroupDropdown } = definition.layout ?? {};
1228
1302
  if (Array.isArray(hideGroupDropdown)) {
1229
1303
  for (const [index, role] of hideGroupDropdown.entries()) {
@@ -1819,6 +1893,9 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
1819
1893
  const report = (instance, message, path) => {
1820
1894
  result.errors.push(new ValidationError(message, instance, undefined, path));
1821
1895
  };
1896
+ const reportUnexpectedError = (error) => {
1897
+ report(null, `Unexpected error: ${error instanceof Error ? error.message : error}`, []);
1898
+ };
1822
1899
  try {
1823
1900
  // IMPORTANT: Create a deep copy to prevent validation logic from mutating the original definition.
1824
1901
  // The imperative/procedural-style algorithms below (e.g., in validateActions) build up arrays
@@ -1834,27 +1911,37 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
1834
1911
  }
1835
1912
  blockVersionMap.get(version.name).set(version.version, version);
1836
1913
  }
1837
- validateController(clonedDefinition, controllerImplementations, report);
1838
- validateCronJobs(clonedDefinition, report);
1839
- validateDefaultPage(clonedDefinition, report);
1840
- validateHooks(clonedDefinition, report);
1841
- validateIcons(clonedDefinition, report);
1842
- validateIconReferences(clonedDefinition, blockVersionMap, report);
1843
- validateLanguage(clonedDefinition, report);
1844
- validateResourceReferences(clonedDefinition, report);
1845
- validateMembersSchema(clonedDefinition, report);
1846
- validatePageParents(clonedDefinition, report);
1847
- validatePhoneNumberDefinition(clonedDefinition, report);
1848
- validateResourceSchemas(clonedDefinition, report);
1849
- validateSecurity(clonedDefinition, report);
1850
- validateBlocks(clonedDefinition, blockVersionMap, report);
1851
- validateActions(clonedDefinition, report);
1852
- validateEvents(clonedDefinition, blockVersionMap, report);
1853
- validateUniquePageNames(clonedDefinition, report);
1854
- validateGridLayout(clonedDefinition, report);
1914
+ // Run each check on its own, so a check that fails does not keep the others from reporting.
1915
+ const check = (validate) => {
1916
+ try {
1917
+ validate();
1918
+ }
1919
+ catch (error) {
1920
+ reportUnexpectedError(error);
1921
+ }
1922
+ };
1923
+ check(() => validateController(clonedDefinition, controllerImplementations, report));
1924
+ check(() => validateCronJobs(clonedDefinition, report));
1925
+ check(() => validateDefaultPage(clonedDefinition, report));
1926
+ check(() => validateHooks(clonedDefinition, report));
1927
+ check(() => validateIcons(clonedDefinition, report));
1928
+ check(() => validateIconReferences(clonedDefinition, blockVersionMap, report));
1929
+ check(() => validateLanguage(clonedDefinition, report));
1930
+ check(() => validateResourceReferences(clonedDefinition, report));
1931
+ check(() => validateMembersSchema(clonedDefinition, report));
1932
+ check(() => validatePageParents(clonedDefinition, report));
1933
+ check(() => validatePhoneNumberDefinition(clonedDefinition, report));
1934
+ check(() => validateResourceSchemas(clonedDefinition, report));
1935
+ check(() => validateSecurity(clonedDefinition, report));
1936
+ check(() => validateBlocks(clonedDefinition, blockVersionMap, report));
1937
+ check(() => validateActions(clonedDefinition, report));
1938
+ check(() => validateEvents(clonedDefinition, blockVersionMap, report));
1939
+ check(() => validateUniquePageNames(clonedDefinition, report));
1940
+ check(() => validateUniqueBlockNames(clonedDefinition, report));
1941
+ check(() => validateGridLayout(clonedDefinition, report));
1855
1942
  }
1856
1943
  catch (error) {
1857
- report(null, `Unexpected error: ${error instanceof Error ? error.message : error}`, []);
1944
+ reportUnexpectedError(error);
1858
1945
  }
1859
1946
  return result;
1860
1947
  }