@appsemble/lang-sdk 0.39.2 → 0.40.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.
@@ -1,5 +1,6 @@
1
1
  import { type CustomFormat, type Options, type Schema, Validator, type ValidatorResult } from 'jsonschema';
2
2
  import { type JsonObject } from 'type-fest';
3
+ import { isValidIconReference } from './icons.js';
3
4
  import { type BlockDefinition } from './index.js';
4
5
  interface ValidatorFactoryOptions {
5
6
  schemas: Record<string, Schema>;
@@ -15,6 +16,7 @@ export declare class BaseValidatorFactory {
15
16
  byte: () => boolean;
16
17
  binary: () => boolean;
17
18
  password: () => boolean;
19
+ icon: typeof isValidIconReference;
18
20
  };
19
21
  private schemas;
20
22
  private customFormats;
package/BasicValidator.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Validator, } from 'jsonschema';
2
+ import { isValidIconReference } from './icons.js';
2
3
  import { schemas as allSchemas } from './index.js';
3
4
  import { escapeJsonPointer } from './jsonPointer.js';
4
5
  import { has } from './miscellaneous.js';
@@ -9,7 +10,10 @@ export class BaseValidatorFactory {
9
10
  }
10
11
  build() {
11
12
  const validator = new Validator();
12
- for (const [key, format] of Object.entries(this.customFormats ?? {})) {
13
+ for (const [key, format] of Object.entries({
14
+ ...BaseValidatorFactory.defaultCustomFormats,
15
+ ...this.customFormats,
16
+ })) {
13
17
  validator.customFormats[key] = format;
14
18
  }
15
19
  for (const [key, schema] of Object.entries(this.schemas)) {
@@ -31,13 +35,13 @@ BaseValidatorFactory.defaultCustomFormats = {
31
35
  byte: () => true,
32
36
  binary: () => true,
33
37
  password: () => true,
38
+ icon: isValidIconReference,
34
39
  };
35
40
  export class BlockExampleValidator {
36
41
  constructor() {
37
42
  this.validator = new BaseValidatorFactory({
38
43
  schemas: allSchemas,
39
44
  customFormats: {
40
- ...BaseValidatorFactory.defaultCustomFormats,
41
45
  fontawesome: () => true,
42
46
  remapper: () => true,
43
47
  action: () => true,
@@ -98,7 +102,6 @@ export class BlockParamInstanceValidator {
98
102
  this.validator = new BaseValidatorFactory({
99
103
  schemas: {},
100
104
  customFormats: {
101
- ...BaseValidatorFactory.defaultCustomFormats,
102
105
  // TODO: validate more
103
106
  fontawesome: () => true,
104
107
  // TODO: validate more
@@ -132,7 +135,6 @@ export class BlockParamSchemaValidator {
132
135
  this.validator = new BaseValidatorFactory({
133
136
  schemas: allSchemas,
134
137
  customFormats: {
135
- ...BaseValidatorFactory.defaultCustomFormats,
136
138
  fontawesome: () => true,
137
139
  remapper: () => true,
138
140
  action: () => true,
@@ -149,9 +151,6 @@ export class AppValidator {
149
151
  constructor() {
150
152
  this.validator = new BaseValidatorFactory({
151
153
  schemas: allSchemas,
152
- customFormats: {
153
- ...BaseValidatorFactory.defaultCustomFormats,
154
- },
155
154
  }).build();
156
155
  }
157
156
  validateApp(app) {
@@ -162,9 +161,6 @@ export class RemapperValidator {
162
161
  constructor() {
163
162
  this.validator = new BaseValidatorFactory({
164
163
  schemas: allSchemas,
165
- customFormats: {
166
- ...BaseValidatorFactory.defaultCustomFormats,
167
- },
168
164
  }).build();
169
165
  }
170
166
  validateRemapper(remapper) {
package/icons.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { type IconName } from '@fortawesome/fontawesome-common-types';
2
+ import { type IconReference, type IconRegistry } from './types/index.js';
3
+ /**
4
+ * The prefix marking a reference to a key in the app’s `icons` registry.
5
+ */
6
+ export declare const customIconPrefix = "icon:";
7
+ /**
8
+ * Check whether a value is a valid icon registry key or icon asset name.
9
+ *
10
+ * @param name The value to check.
11
+ * @returns Whether the value matches the icon name grammar.
12
+ */
13
+ export declare function isValidIconName(name: unknown): name is string;
14
+ export type ParsedIconReference = {
15
+ type: 'custom';
16
+ key: string;
17
+ } | {
18
+ type: 'fontawesome';
19
+ name: IconName;
20
+ } | {
21
+ type: 'invalid';
22
+ reason: string;
23
+ };
24
+ /**
25
+ * Parse an icon reference without consulting a registry.
26
+ *
27
+ * Bare names are treated as Font Awesome names without checking them against the Font Awesome
28
+ * icon set. Only the `icon:` prefix is recognized; any other colon-separated prefix is rejected.
29
+ *
30
+ * @param reference The value to parse.
31
+ * @returns The parsed reference, or an invalid result with a reason.
32
+ */
33
+ export declare function parseIconReference(reference: unknown): ParsedIconReference;
34
+ /**
35
+ * Check whether a value is a syntactically valid icon reference.
36
+ *
37
+ * This is the check behind the `icon` schema format. It doesn’t consult a registry.
38
+ *
39
+ * @param reference The value to check.
40
+ * @returns Whether the value is a bare icon name or a well-formed `icon:<key>` reference.
41
+ */
42
+ export declare function isValidIconReference(reference: unknown): reference is IconReference;
43
+ export type ResolvedIcon = {
44
+ type: 'asset';
45
+ key: string;
46
+ asset: string;
47
+ } | {
48
+ type: 'fontawesome';
49
+ name: IconName;
50
+ } | {
51
+ type: 'invalid';
52
+ reason: string;
53
+ };
54
+ /**
55
+ * Resolve an icon reference against an icon registry.
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.
59
+ *
60
+ * @param reference The icon reference to resolve.
61
+ * @param registry The app’s icon registry.
62
+ * @returns A Font Awesome icon name, a validated asset name, or an invalid result with a reason.
63
+ */
64
+ export declare function resolveIconReference(reference: unknown, registry?: IconRegistry | null): ResolvedIcon;
package/icons.js ADDED
@@ -0,0 +1,90 @@
1
+ import { normalized } from './constants/index.js';
2
+ import { has } from './miscellaneous.js';
3
+ /**
4
+ * The prefix marking a reference to a key in the app’s `icons` registry.
5
+ */
6
+ export const customIconPrefix = 'icon:';
7
+ /**
8
+ * Check whether a value is a valid icon registry key or icon asset name.
9
+ *
10
+ * @param name The value to check.
11
+ * @returns Whether the value matches the icon name grammar.
12
+ */
13
+ export function isValidIconName(name) {
14
+ return typeof name === 'string' && normalized.test(name);
15
+ }
16
+ /**
17
+ * Parse an icon reference without consulting a registry.
18
+ *
19
+ * Bare names are treated as Font Awesome names without checking them against the Font Awesome
20
+ * icon set. Only the `icon:` prefix is recognized; any other colon-separated prefix is rejected.
21
+ *
22
+ * @param reference The value to parse.
23
+ * @returns The parsed reference, or an invalid result with a reason.
24
+ */
25
+ export function parseIconReference(reference) {
26
+ if (typeof reference !== 'string') {
27
+ return { type: 'invalid', reason: 'must be a string' };
28
+ }
29
+ if (!reference) {
30
+ return { type: 'invalid', reason: 'must not be empty' };
31
+ }
32
+ if (reference.startsWith(customIconPrefix)) {
33
+ const key = reference.slice(customIconPrefix.length);
34
+ if (!isValidIconName(key)) {
35
+ return {
36
+ type: 'invalid',
37
+ reason: `has the invalid icon key “${key}”; keys must match ${normalized.source}`,
38
+ };
39
+ }
40
+ return { type: 'custom', key };
41
+ }
42
+ if (reference.includes(':')) {
43
+ return {
44
+ type: 'invalid',
45
+ reason: `uses the unsupported prefix “${reference.slice(0, reference.indexOf(':') + 1)}”; only “${customIconPrefix}” is supported`,
46
+ };
47
+ }
48
+ return { type: 'fontawesome', name: reference };
49
+ }
50
+ /**
51
+ * Check whether a value is a syntactically valid icon reference.
52
+ *
53
+ * This is the check behind the `icon` schema format. It doesn’t consult a registry.
54
+ *
55
+ * @param reference The value to check.
56
+ * @returns Whether the value is a bare icon name or a well-formed `icon:<key>` reference.
57
+ */
58
+ export function isValidIconReference(reference) {
59
+ return parseIconReference(reference).type !== 'invalid';
60
+ }
61
+ /**
62
+ * Resolve an icon reference against an icon registry.
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.
66
+ *
67
+ * @param reference The icon reference to resolve.
68
+ * @param registry The app’s icon registry.
69
+ * @returns A Font Awesome icon name, a validated asset name, or an invalid result with a reason.
70
+ */
71
+ export function resolveIconReference(reference, registry) {
72
+ const parsed = parseIconReference(reference);
73
+ if (parsed.type !== 'custom') {
74
+ return parsed;
75
+ }
76
+ const { key } = parsed;
77
+ if (typeof registry !== 'object' || !registry || !has(registry, key)) {
78
+ return { type: 'invalid', reason: `references the unknown icon key “${key}”` };
79
+ }
80
+ const entry = registry[key];
81
+ const asset = typeof entry === 'object' && entry ? entry.asset : null;
82
+ if (!isValidIconName(asset)) {
83
+ return {
84
+ type: 'invalid',
85
+ reason: `references the icon key “${key}”, which has an invalid asset name`,
86
+ };
87
+ }
88
+ return { type: 'asset', key, asset };
89
+ }
90
+ //# sourceMappingURL=icons.js.map
package/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './blockUtils.js';
7
7
  export * from './validation.js';
8
8
  export * from './findPageByName.js';
9
9
  export * from './gridUtils.js';
10
+ export * from './icons.js';
10
11
  export * from './pageUtils.js';
11
12
  export * from './iterApp.js';
12
13
  export * from './serverActions.js';
package/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './blockUtils.js';
7
7
  export * from './validation.js';
8
8
  export * from './findPageByName.js';
9
9
  export * from './gridUtils.js';
10
+ export * from './icons.js';
10
11
  export * from './pageUtils.js';
11
12
  export * from './iterApp.js';
12
13
  export * from './serverActions.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/lang-sdk",
3
- "version": "0.39.2",
3
+ "version": "0.40.1",
4
4
  "description": "Language SDK for Appsemble",
5
5
  "keywords": [
6
6
  "app",
@@ -91,6 +91,16 @@ The most basic resource has a \`schema\` property and defines the minimal securi
91
91
  theme: {
92
92
  $ref: '#/components/schemas/Theme',
93
93
  },
94
+ icons: {
95
+ type: 'object',
96
+ description: `Custom icons that can be referenced as \`icon:<key>\` wherever an icon is accepted.
97
+
98
+ Each key names an app-level SVG asset by its name. Keys and asset names must consist of lower case
99
+ letters, digits, and single hyphens. Keys are case-sensitive, and \`icon:<key>\` never falls back
100
+ to a Font Awesome icon.
101
+ `,
102
+ additionalProperties: { $ref: '#/components/schemas/IconRegistryEntryDefinition' },
103
+ },
94
104
  contentSecurityPolicy: {
95
105
  type: 'object',
96
106
  description: `Additional CSP source expressions for the published app page.
@@ -48,9 +48,11 @@ navigation. Set to \`hidden\` to display no navigational menus at all.
48
48
  },
49
49
  icon: {
50
50
  type: 'string',
51
- description: `An optional icon from the fontawesome icon set
51
+ format: 'icon',
52
+ description: `An optional icon from the Font Awesome icon set, or a custom icon reference.
52
53
 
53
- This will be displayed in the navigation menu.
54
+ This will be displayed in the navigation menu. Use \`icon:<key>\` to reference a key from the app’s
55
+ \`icons\` registry.
54
56
  `,
55
57
  },
56
58
  parent: {
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const IconRegistryEntryDefinition: OpenAPIV3.NonArraySchemaObject;
@@ -0,0 +1,19 @@
1
+ import { normalized } from '../constants/index.js';
2
+ export const IconRegistryEntryDefinition = {
3
+ type: 'object',
4
+ description: 'A custom icon in the app’s `icons` registry.',
5
+ required: ['asset'],
6
+ additionalProperties: false,
7
+ properties: {
8
+ asset: {
9
+ type: 'string',
10
+ pattern: normalized.source,
11
+ description: `The name of the app-level asset holding the icon’s SVG artwork.
12
+
13
+ This is an asset name, not a URL or asset ID. The asset doesn’t need to exist when the app is
14
+ published, but the icon only renders once an SVG asset with this name has been uploaded.
15
+ `,
16
+ },
17
+ },
18
+ };
19
+ //# sourceMappingURL=IconRegistryEntryDefinition.js.map
@@ -132,3 +132,4 @@ export * from './NavbarDeviceGridLayoutDefinition.js';
132
132
  export * from './NavbarLayoutDefinition.js';
133
133
  export * from './PageLayoutDefinition.js';
134
134
  export * from './PageParentDefinition.js';
135
+ export * from './IconRegistryEntryDefinition.js';
package/schemas/index.js CHANGED
@@ -132,4 +132,5 @@ export * from './NavbarDeviceGridLayoutDefinition.js';
132
132
  export * from './NavbarLayoutDefinition.js';
133
133
  export * from './PageLayoutDefinition.js';
134
134
  export * from './PageParentDefinition.js';
135
+ export * from './IconRegistryEntryDefinition.js';
135
136
  //# sourceMappingURL=index.js.map
@@ -68,6 +68,7 @@ export const JSONSchemaString = extendJSONSchema(BaseJSONSchema, {
68
68
  'event-emitter',
69
69
  'event-listener',
70
70
  'fontawesome',
71
+ 'icon',
71
72
  ],
72
73
  description: 'The format values should adhere to.',
73
74
  },
@@ -1,2 +1,2 @@
1
- export declare const serverActions: Set<"each" | "email" | "app.member.query" | "condition" | "log" | "noop" | "notify" | "request" | "resource.create" | "resource.delete" | "resource.delete.all" | "resource.delete.bulk" | "resource.get" | "resource.patch" | "resource.query" | "resource.update" | "static" | "throw">;
1
+ export declare const serverActions: Set<"email" | "log" | "static" | "noop" | "throw" | "resource.get" | "resource.patch" | "resource.query" | "resource.update" | "app.member.query" | "condition" | "each" | "notify" | "request" | "resource.create" | "resource.delete.all" | "resource.delete.bulk" | "resource.delete">;
2
2
  export type ServerActionName = typeof serverActions extends Set<infer T> ? T : never;
package/types/core.d.ts CHANGED
@@ -12,6 +12,23 @@ import { type Theme } from './theme.js';
12
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
+ /**
16
+ * A reference to an icon.
17
+ *
18
+ * A bare name refers to a Font Awesome icon. A name prefixed with `icon:` refers to a key in the
19
+ * app’s `icons` registry, which maps it to an uploaded SVG asset.
20
+ */
21
+ export type IconReference = IconName | `icon:${string}`;
22
+ export interface IconRegistryEntry {
23
+ /**
24
+ * The name of the app-level asset holding the icon’s SVG artwork.
25
+ */
26
+ asset: string;
27
+ }
28
+ /**
29
+ * A mapping of semantic icon keys to the assets holding their artwork.
30
+ */
31
+ export type IconRegistry = Record<string, IconRegistryEntry>;
15
32
  export interface AppDefinition {
16
33
  /**
17
34
  * The name of the app.
@@ -171,6 +188,12 @@ export interface AppDefinition {
171
188
  * The global theme for the app.
172
189
  */
173
190
  theme?: Partial<Theme>;
191
+ /**
192
+ * Custom icons that may be referenced as `icon:<key>` from pages, blocks, and login buttons.
193
+ *
194
+ * Each key maps to an app-level SVG asset by name.
195
+ */
196
+ icons?: IconRegistry;
174
197
  /**
175
198
  * Extra content security policy source expressions for the published app page.
176
199
  *
@@ -257,11 +280,12 @@ export interface BasePageDefinition {
257
280
  */
258
281
  roles?: ViewRole[];
259
282
  /**
260
- * An optional icon from the fontawesome icon set
283
+ * An optional icon from the Font Awesome icon set, or an `icon:<key>` reference to the app’s
284
+ * `icons` registry.
261
285
  *
262
286
  * This will be displayed in the navigation menu.
263
287
  */
264
- icon?: IconName;
288
+ icon?: IconReference;
265
289
  /**
266
290
  * Page parameters can be used for linking to a page that should display a single resource.
267
291
  */
package/validation.js CHANGED
@@ -7,6 +7,7 @@ import { getAppBlocks, normalizeBlockName } from './blockUtils.js';
7
7
  import { partialNormalized } from './constants/index.js';
8
8
  import { findPageByName } from './findPageByName.js';
9
9
  import { breadcrumbsGridArea, getCascadedGridTemplateAreas, getGridTemplateAreas, gridDeviceOrder, } from './gridUtils.js';
10
+ import { isValidIconName, parseIconReference } from './icons.js';
10
11
  import { iterAction, iterApp } from './iterApp.js';
11
12
  import { has } from './miscellaneous.js';
12
13
  import { normalize } from './normalize.js';
@@ -1238,6 +1239,101 @@ function validateResourceReferences(definition, report) {
1238
1239
  }
1239
1240
  }
1240
1241
  }
1242
+ function validateIcons({ icons }, report) {
1243
+ if (!icons) {
1244
+ return;
1245
+ }
1246
+ for (const key of Object.keys(icons)) {
1247
+ if (!isValidIconName(key)) {
1248
+ report(key, 'is not a valid icon key; use lower case letters, digits, and single hyphens', [
1249
+ 'icons',
1250
+ key,
1251
+ ]);
1252
+ }
1253
+ }
1254
+ }
1255
+ /**
1256
+ * Collect the string values of an instance which its schema marks with the given format.
1257
+ *
1258
+ * Schema references, nested objects, arrays, and composition keywords are followed.
1259
+ *
1260
+ * @param instance The value to inspect.
1261
+ * @param schema The schema describing the value.
1262
+ * @param root The root schema, used to resolve local `$ref`s to its definitions.
1263
+ * @param format The format to look for.
1264
+ * @param path The path of the instance, used for reporting.
1265
+ * @param found The collected values, keyed by their JSON path.
1266
+ */
1267
+ function collectFormattedStrings(instance, schema, root, format, path, found) {
1268
+ if (!schema || typeof schema !== 'object') {
1269
+ return;
1270
+ }
1271
+ if (schema.$ref) {
1272
+ const match = /^#\/definitions\/(.+)$/.exec(schema.$ref);
1273
+ if (match) {
1274
+ collectFormattedStrings(instance, root.definitions?.[decodeURIComponent(match[1])], root, format, path, found);
1275
+ }
1276
+ return;
1277
+ }
1278
+ if (typeof instance === 'string') {
1279
+ if (schema.format === format) {
1280
+ found.set(JSON.stringify(path), [path, instance]);
1281
+ }
1282
+ }
1283
+ else if (Array.isArray(instance)) {
1284
+ for (const [index, item] of instance.entries()) {
1285
+ const itemSchema = Array.isArray(schema.items)
1286
+ ? (schema.items[index] ??
1287
+ (typeof schema.additionalItems === 'object' ? schema.additionalItems : undefined))
1288
+ : schema.items;
1289
+ collectFormattedStrings(item, itemSchema, root, format, [...path, index], found);
1290
+ }
1291
+ }
1292
+ else if (typeof instance === 'object' && instance) {
1293
+ for (const [key, value] of Object.entries(instance)) {
1294
+ const propertySchema = has(schema.properties, key)
1295
+ ? schema.properties[key]
1296
+ : (Object.entries(schema.patternProperties ?? {}).find(([pattern]) => new RegExp(pattern).test(key))?.[1] ??
1297
+ (typeof schema.additionalProperties === 'object'
1298
+ ? schema.additionalProperties
1299
+ : undefined));
1300
+ collectFormattedStrings(value, propertySchema, root, format, [...path, key], found);
1301
+ }
1302
+ }
1303
+ for (const branches of [schema.anyOf, schema.oneOf, schema.allOf]) {
1304
+ for (const branch of branches ?? []) {
1305
+ collectFormattedStrings(instance, branch, root, format, path, found);
1306
+ }
1307
+ }
1308
+ }
1309
+ function validateIconReferences(definition, blockVersions, report) {
1310
+ const check = (reference, path) => {
1311
+ // Syntax errors are reported by the schema validation and invalid registry entries by
1312
+ // validateIcons; only check registry membership here.
1313
+ const parsed = parseIconReference(reference);
1314
+ if (parsed.type === 'custom' && !has(definition.icons, parsed.key)) {
1315
+ report(reference, `references the unknown icon key “${parsed.key}”`, path);
1316
+ }
1317
+ };
1318
+ iterApp(definition, {
1319
+ onPage(page, path) {
1320
+ if (typeof page.icon === 'string') {
1321
+ check(page.icon, [...path, 'icon']);
1322
+ }
1323
+ },
1324
+ onBlock(block, path) {
1325
+ const version = blockVersions.get(normalizeBlockName(block.type))?.get(block.version);
1326
+ if (!version?.parameters || !block.parameters) {
1327
+ return;
1328
+ }
1329
+ const found = new Map();
1330
+ collectFormattedStrings(block.parameters, version.parameters, version.parameters, 'icon', [...path, 'parameters'], found);
1331
+ for (const [iconPath, reference] of found.values()) {
1332
+ check(reference, iconPath);
1333
+ }
1334
+ },
1335
+ });
1336
+ }
1241
1337
  function validateLanguage({ defaultLanguage }, report) {
1242
1338
  if (defaultLanguage != null && !languageTags.check(defaultLanguage)) {
1243
1339
  report(defaultLanguage, 'is not a valid language code', ['defaultLanguage']);
@@ -1667,6 +1763,8 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
1667
1763
  validateCronJobs(clonedDefinition, report);
1668
1764
  validateDefaultPage(clonedDefinition, report);
1669
1765
  validateHooks(clonedDefinition, report);
1766
+ validateIcons(clonedDefinition, report);
1767
+ validateIconReferences(clonedDefinition, blockVersionMap, report);
1670
1768
  validateLanguage(clonedDefinition, report);
1671
1769
  validateResourceReferences(clonedDefinition, report);
1672
1770
  validateMembersSchema(clonedDefinition, report);