@brydio/manifest 0.1.0-alpha.5 → 0.1.0-alpha.51
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/README.md +1 -1
- package/package.json +2 -2
- package/src/anonymous.d.ts +42 -0
- package/src/anonymous.js +86 -0
- package/src/base.d.ts +17 -1
- package/src/base.js +13 -1
- package/src/bundle.d.ts +8 -1
- package/src/bundle.js +28 -3
- package/src/confirm-email.d.ts +40 -0
- package/src/confirm-email.js +78 -0
- package/src/define.d.ts +1 -1
- package/src/define.js +1 -1
- package/src/field-types.d.ts +40 -4
- package/src/field-types.js +59 -9
- package/src/grants.d.ts +12 -2
- package/src/grants.js +28 -2
- package/src/index.d.ts +10 -5
- package/src/index.js +10 -5
- package/src/migrations.d.ts +1 -1
- package/src/migrations.js +29 -0
- package/src/open-schema.d.ts +128 -0
- package/src/open-schema.js +444 -0
- package/src/readers.d.ts +54 -0
- package/src/readers.js +96 -0
- package/src/requirements.d.ts +122 -0
- package/src/requirements.js +692 -0
- package/src/schema.d.ts +539 -8
- package/src/schema.js +440 -27
- package/src/secrets.d.ts +14 -0
- package/src/secrets.js +38 -5
- package/src/validate.d.ts +8 -2
- package/src/validate.js +7 -5
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ export const manifest = defineManifest({
|
|
|
11
11
|
name: 'checklist',
|
|
12
12
|
version: '0.1.0',
|
|
13
13
|
screens: { home: { entry: 'screens/home.js' } },
|
|
14
|
-
placements: [{ kind: 'project-
|
|
14
|
+
placements: [{ kind: 'project-widget', screen: 'home', label: 'Checklist', sizes: ['medium', 'large'] }],
|
|
15
15
|
});
|
|
16
16
|
```
|
|
17
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brydio/manifest",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.51",
|
|
4
4
|
"description": "The shape of a Brydio app's manifest, its collections' schemas, and the checks that say what is wrong with one.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"url": "https://github.com/nakel-ola/brydio-sdk",
|
|
11
11
|
"directory": "packages/manifest"
|
|
12
12
|
},
|
|
13
|
-
"gitHead": "
|
|
13
|
+
"gitHead": "419efcfb770b0f32bb6143ca56e7ef05a83653f4",
|
|
14
14
|
"exports": {
|
|
15
15
|
".": {
|
|
16
16
|
"types": "./src/index.d.ts",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* An anonymous collection (P13): answers nobody can tie back to the person
|
|
4
|
+
* who gave them. Mirrors Brydio's `apps/api/src/apps/manifest/anonymous.ts`;
|
|
5
|
+
* the two change together.
|
|
6
|
+
*
|
|
7
|
+
* An app only declares it: which structured field groups the answers (a
|
|
8
|
+
* form, a survey round: a number, a choice, a date), and how many answers a
|
|
9
|
+
* group must hold before any of them is read. Brydio does the rest in the store, never the app: no writer kept
|
|
10
|
+
* on the record, times cut to the day, no live change told, no update, a
|
|
11
|
+
* create naming its own writer refused, and reads only of a whole group of
|
|
12
|
+
* at least `minimum` answers.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ANONYMOUS_LIMITS: {
|
|
15
|
+
/** The fewest answers a group, or a narrowed read of one, may be read at. */
|
|
16
|
+
readonly minimum: 5;
|
|
17
|
+
/** The most an app may ask for. */
|
|
18
|
+
readonly maximum: 1000;
|
|
19
|
+
};
|
|
20
|
+
/** What the store writes as `created_by` on every anonymous record. */
|
|
21
|
+
export declare const ANONYMOUS_WRITER = "anonymous";
|
|
22
|
+
export declare const anonymousSchema: z.ZodObject<{
|
|
23
|
+
group: z.ZodString;
|
|
24
|
+
minimum: z.ZodOptional<z.ZodNumber>;
|
|
25
|
+
}, z.core.$strict>;
|
|
26
|
+
export type AnonymousDeclared = z.infer<typeof anonymousSchema>;
|
|
27
|
+
/** As the host reads it, on `CollectionSpec.anonymous`. */
|
|
28
|
+
export interface AnonymousSpec {
|
|
29
|
+
group: string;
|
|
30
|
+
minimum: number;
|
|
31
|
+
}
|
|
32
|
+
export type AnonymousProblemCode = 'data_anonymous_group' | 'data_anonymous_member' | 'data_anonymous_minimum' | 'data_anonymous_coedit';
|
|
33
|
+
export declare function anonymousProblems(collection: string, declared: {
|
|
34
|
+
schema: Record<string, unknown>;
|
|
35
|
+
anonymous?: AnonymousDeclared;
|
|
36
|
+
}): {
|
|
37
|
+
code: AnonymousProblemCode;
|
|
38
|
+
collection: string;
|
|
39
|
+
field?: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}[];
|
|
42
|
+
export declare function anonymousSpec(declared: AnonymousDeclared): AnonymousSpec;
|
package/src/anonymous.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { FIELD_LIMITS, isStructured, parseFieldType, } from "./field-types.js";
|
|
3
|
+
/**
|
|
4
|
+
* An anonymous collection (P13): answers nobody can tie back to the person
|
|
5
|
+
* who gave them. Mirrors Brydio's `apps/api/src/apps/manifest/anonymous.ts`;
|
|
6
|
+
* the two change together.
|
|
7
|
+
*
|
|
8
|
+
* An app only declares it: which structured field groups the answers (a
|
|
9
|
+
* form, a survey round: a number, a choice, a date), and how many answers a
|
|
10
|
+
* group must hold before any of them is read. Brydio does the rest in the store, never the app: no writer kept
|
|
11
|
+
* on the record, times cut to the day, no live change told, no update, a
|
|
12
|
+
* create naming its own writer refused, and reads only of a whole group of
|
|
13
|
+
* at least `minimum` answers.
|
|
14
|
+
*/
|
|
15
|
+
export const ANONYMOUS_LIMITS = {
|
|
16
|
+
/** The fewest answers a group, or a narrowed read of one, may be read at. */
|
|
17
|
+
minimum: 5,
|
|
18
|
+
/** The most an app may ask for. */
|
|
19
|
+
maximum: 1000,
|
|
20
|
+
};
|
|
21
|
+
/** What the store writes as `created_by` on every anonymous record. */
|
|
22
|
+
export const ANONYMOUS_WRITER = 'anonymous';
|
|
23
|
+
export const anonymousSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
/** A structured field of the same collection, never a member: which group an answer belongs to. */
|
|
26
|
+
group: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
27
|
+
/** At least 5, the default. */
|
|
28
|
+
minimum: z.number().int().max(ANONYMOUS_LIMITS.maximum).optional(),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
export function anonymousProblems(collection, declared) {
|
|
32
|
+
const anonymous = declared.anonymous;
|
|
33
|
+
if (!anonymous)
|
|
34
|
+
return [];
|
|
35
|
+
const problems = [];
|
|
36
|
+
const types = new Map();
|
|
37
|
+
for (const [field, raw] of Object.entries(declared.schema)) {
|
|
38
|
+
try {
|
|
39
|
+
types.set(field, parseFieldType(raw));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Refused as a field type already.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Kept in plain, so a read can name its group; words never are.
|
|
46
|
+
const group = types.get(anonymous.group);
|
|
47
|
+
if (!group || !isStructured(group) || group.kind === 'member') {
|
|
48
|
+
problems.push({
|
|
49
|
+
code: 'data_anonymous_group',
|
|
50
|
+
collection,
|
|
51
|
+
field: anonymous.group,
|
|
52
|
+
message: `${collection}.anonymous.group must name one of its structured fields (a number, choice, date, boolean, token or project); ${anonymous.group} is not.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (anonymous.minimum !== undefined && anonymous.minimum < ANONYMOUS_LIMITS.minimum) {
|
|
56
|
+
problems.push({
|
|
57
|
+
code: 'data_anonymous_minimum',
|
|
58
|
+
collection,
|
|
59
|
+
message: `${collection}.anonymous.minimum is at least ${ANONYMOUS_LIMITS.minimum}: fewer answers than that can be told apart.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
for (const [field, type] of types) {
|
|
63
|
+
// A member field names a person on the record, which is what this is for not doing.
|
|
64
|
+
if (type.kind === 'member') {
|
|
65
|
+
problems.push({
|
|
66
|
+
code: 'data_anonymous_member',
|
|
67
|
+
collection,
|
|
68
|
+
field,
|
|
69
|
+
message: `${collection} is anonymous, so it can't name a member: ${field} would.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// Writing together shows who is typing, and changes a record after it is given.
|
|
73
|
+
if (type.coedit) {
|
|
74
|
+
problems.push({
|
|
75
|
+
code: 'data_anonymous_coedit',
|
|
76
|
+
collection,
|
|
77
|
+
field,
|
|
78
|
+
message: `${collection} is anonymous, so its answers can't be written together: ${field} would be.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return problems;
|
|
83
|
+
}
|
|
84
|
+
export function anonymousSpec(declared) {
|
|
85
|
+
return { group: declared.group, minimum: declared.minimum ?? ANONYMOUS_LIMITS.minimum };
|
|
86
|
+
}
|
package/src/base.d.ts
CHANGED
|
@@ -32,6 +32,15 @@ export declare const linksSchema: z.ZodObject<{
|
|
|
32
32
|
terms: z.ZodOptional<z.ZodString>;
|
|
33
33
|
support: z.ZodOptional<z.ZodString>;
|
|
34
34
|
}, z.core.$strip>;
|
|
35
|
+
/**
|
|
36
|
+
* One of an app's two images, in its two variants (ADR-A20): `color` for a
|
|
37
|
+
* tile and the app's own page, `mono` for the sidebar, where the theme tints
|
|
38
|
+
* it. Both are `./`-prefixed paths inside the app.
|
|
39
|
+
*/
|
|
40
|
+
export declare const brandImagesSchema: z.ZodObject<{
|
|
41
|
+
color: z.ZodString;
|
|
42
|
+
mono: z.ZodString;
|
|
43
|
+
}, z.core.$strip>;
|
|
35
44
|
export declare const requiresSchema: z.ZodObject<{
|
|
36
45
|
servers: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
37
46
|
integrations: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
@@ -57,7 +66,14 @@ export declare const baseManifestSchema: z.ZodObject<{
|
|
|
57
66
|
terms: z.ZodOptional<z.ZodString>;
|
|
58
67
|
support: z.ZodOptional<z.ZodString>;
|
|
59
68
|
}, z.core.$strip>>;
|
|
60
|
-
|
|
69
|
+
logo: z.ZodOptional<z.ZodObject<{
|
|
70
|
+
color: z.ZodString;
|
|
71
|
+
mono: z.ZodString;
|
|
72
|
+
}, z.core.$strip>>;
|
|
73
|
+
icon: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
74
|
+
color: z.ZodString;
|
|
75
|
+
mono: z.ZodString;
|
|
76
|
+
}, z.core.$strip>]>>;
|
|
61
77
|
brandColor: z.ZodOptional<z.ZodString>;
|
|
62
78
|
brandColorDark: z.ZodOptional<z.ZodString>;
|
|
63
79
|
defaultPrompts: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/src/base.js
CHANGED
|
@@ -32,6 +32,15 @@ export const linksSchema = z.object({
|
|
|
32
32
|
terms: z.string().optional(),
|
|
33
33
|
support: z.string().optional(),
|
|
34
34
|
});
|
|
35
|
+
/**
|
|
36
|
+
* One of an app's two images, in its two variants (ADR-A20): `color` for a
|
|
37
|
+
* tile and the app's own page, `mono` for the sidebar, where the theme tints
|
|
38
|
+
* it. Both are `./`-prefixed paths inside the app.
|
|
39
|
+
*/
|
|
40
|
+
export const brandImagesSchema = z.object({
|
|
41
|
+
color: z.string(),
|
|
42
|
+
mono: z.string(),
|
|
43
|
+
});
|
|
35
44
|
export const requiresSchema = z.object({
|
|
36
45
|
servers: z.array(z.string()).default([]),
|
|
37
46
|
integrations: z.array(z.string()).default([]),
|
|
@@ -49,7 +58,10 @@ export const baseManifestSchema = z.object({
|
|
|
49
58
|
license: z.string().max(MANIFEST_LIMITS.licenseChars).optional(),
|
|
50
59
|
keywords: z.array(z.string()).max(MANIFEST_LIMITS.keywords).optional(),
|
|
51
60
|
links: linksSchema.optional(),
|
|
52
|
-
|
|
61
|
+
/** Required to publish (ADR-A20); optional here so every stored manifest still reads. */
|
|
62
|
+
logo: brandImagesSchema.optional(),
|
|
63
|
+
/** The two-variant icon (ADR-A20), or the one path every app had before it, still read. */
|
|
64
|
+
icon: z.union([z.string(), brandImagesSchema]).optional(),
|
|
53
65
|
brandColor: z.string().optional(),
|
|
54
66
|
brandColorDark: z.string().optional(),
|
|
55
67
|
defaultPrompts: z.array(z.string()).max(MANIFEST_LIMITS.defaultPrompts).optional(),
|
package/src/bundle.d.ts
CHANGED
|
@@ -23,7 +23,14 @@ export type BundleFiles = ReadonlyMap<string, Uint8Array>;
|
|
|
23
23
|
export declare function isBundlePath(path: string): boolean;
|
|
24
24
|
/** Whether a path is a script a bundle can hold. */
|
|
25
25
|
export declare const isScriptPath: (path: string) => boolean;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The logo and icon files the bundle's manifest names (ADR-A20), by path: the
|
|
28
|
+
* one kind of file beside code a bundle may hold, and only at those paths.
|
|
29
|
+
* Brydio keeps them with the version, never as code, so they are not in the
|
|
30
|
+
* fingerprint.
|
|
31
|
+
*/
|
|
32
|
+
export declare function brandOf(files: BundleFiles): BundleFiles;
|
|
33
|
+
/** The files the store keeps and the fingerprint covers: every one but the root manifest and its brand images. */
|
|
27
34
|
export declare function codeOf(files: BundleFiles): BundleFiles;
|
|
28
35
|
/**
|
|
29
36
|
* The fingerprint: sha256, lowercase hex, over the sorted list of the code
|
package/src/bundle.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { brandPathsOf } from "./requirements.js";
|
|
2
3
|
/**
|
|
3
4
|
* What a bundle may hold, and the fingerprint its code is served under
|
|
4
5
|
* (contracts §11, A7-F02).
|
|
@@ -29,9 +30,32 @@ export function isBundlePath(path) {
|
|
|
29
30
|
}
|
|
30
31
|
/** Whether a path is a script a bundle can hold. */
|
|
31
32
|
export const isScriptPath = (path) => isBundlePath(path) && SCRIPT.test(path);
|
|
32
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* The logo and icon files the bundle's manifest names (ADR-A20), by path: the
|
|
35
|
+
* one kind of file beside code a bundle may hold, and only at those paths.
|
|
36
|
+
* Brydio keeps them with the version, never as code, so they are not in the
|
|
37
|
+
* fingerprint.
|
|
38
|
+
*/
|
|
39
|
+
export function brandOf(files) {
|
|
40
|
+
const named = new Set(brandPathsOf(manifestIn(files)).values());
|
|
41
|
+
return new Map([...files].filter(([path]) => named.has(path)));
|
|
42
|
+
}
|
|
43
|
+
/** The bundle's manifest, read leniently: anything unreadable names no files. */
|
|
44
|
+
function manifestIn(files) {
|
|
45
|
+
const bytes = files.get(BUNDLE_MANIFEST);
|
|
46
|
+
if (!bytes)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The files the store keeps and the fingerprint covers: every one but the root manifest and its brand images. */
|
|
33
56
|
export function codeOf(files) {
|
|
34
|
-
|
|
57
|
+
const brand = brandOf(files);
|
|
58
|
+
return new Map([...files].filter(([path]) => path !== BUNDLE_MANIFEST && !brand.has(path)));
|
|
35
59
|
}
|
|
36
60
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
37
61
|
/**
|
|
@@ -54,11 +78,12 @@ export function bundleHash(files) {
|
|
|
54
78
|
* and words: every path first, then the manifest, then the code, then the size.
|
|
55
79
|
*/
|
|
56
80
|
export function bundleProblem(files) {
|
|
81
|
+
const brand = brandOf(files);
|
|
57
82
|
for (const path of files.keys()) {
|
|
58
83
|
if (!isBundlePath(path)) {
|
|
59
84
|
return { code: 'bundle_path_invalid', message: `"${JSON.stringify(path).slice(1, -1)}" is not a path a bundle can hold.`, file: path };
|
|
60
85
|
}
|
|
61
|
-
if (path !== BUNDLE_MANIFEST && !SCRIPT.test(path)) {
|
|
86
|
+
if (path !== BUNDLE_MANIFEST && !brand.has(path) && !SCRIPT.test(path)) {
|
|
62
87
|
return {
|
|
63
88
|
code: 'bundle_file_not_code',
|
|
64
89
|
message: `"${path}" is not a script. A bundle holds only .js files and ${BUNDLE_MANIFEST}.`,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The confirmation email of a public form (FO04). Mirrors Brydio's
|
|
4
|
+
* `apps/api/src/apps/manifest/confirm-email.ts`; the two change together.
|
|
5
|
+
*
|
|
6
|
+
* An app only declares it: which field of a `publicSubmit` collection holds
|
|
7
|
+
* the visitor's address, and a short plain text to say. Brydio decides the
|
|
8
|
+
* rest — when it is sent, to whom, how often, from whom — so an anonymous
|
|
9
|
+
* page can never be used to send anybody anything else.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CONFIRM_LIMITS: {
|
|
12
|
+
readonly subjectChars: 120;
|
|
13
|
+
readonly messageChars: 600;
|
|
14
|
+
};
|
|
15
|
+
export declare const confirmEmailSchema: z.ZodObject<{
|
|
16
|
+
field: z.ZodString;
|
|
17
|
+
subject: z.ZodOptional<z.ZodString>;
|
|
18
|
+
message: z.ZodOptional<z.ZodString>;
|
|
19
|
+
link: z.ZodOptional<z.ZodBoolean>;
|
|
20
|
+
}, z.core.$strict>;
|
|
21
|
+
export type ConfirmEmailDeclared = z.infer<typeof confirmEmailSchema>;
|
|
22
|
+
/** As the host reads it, on `CollectionSpec.confirmEmail`. */
|
|
23
|
+
export interface ConfirmEmailSpec {
|
|
24
|
+
field: string;
|
|
25
|
+
subject: string | null;
|
|
26
|
+
message: string | null;
|
|
27
|
+
link: boolean;
|
|
28
|
+
}
|
|
29
|
+
export type ConfirmProblemCode = 'data_confirm_submit' | 'data_confirm_field' | 'data_confirm_text';
|
|
30
|
+
export declare function confirmEmailProblems(collection: string, declared: {
|
|
31
|
+
schema: Record<string, unknown>;
|
|
32
|
+
publicSubmit?: boolean;
|
|
33
|
+
confirmEmail?: ConfirmEmailDeclared;
|
|
34
|
+
}): {
|
|
35
|
+
code: ConfirmProblemCode;
|
|
36
|
+
collection: string;
|
|
37
|
+
field?: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}[];
|
|
40
|
+
export declare function confirmEmailSpec(declared: ConfirmEmailDeclared): ConfirmEmailSpec;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { FIELD_LIMITS, parseFieldType } from "./field-types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The confirmation email of a public form (FO04). Mirrors Brydio's
|
|
5
|
+
* `apps/api/src/apps/manifest/confirm-email.ts`; the two change together.
|
|
6
|
+
*
|
|
7
|
+
* An app only declares it: which field of a `publicSubmit` collection holds
|
|
8
|
+
* the visitor's address, and a short plain text to say. Brydio decides the
|
|
9
|
+
* rest — when it is sent, to whom, how often, from whom — so an anonymous
|
|
10
|
+
* page can never be used to send anybody anything else.
|
|
11
|
+
*/
|
|
12
|
+
export const CONFIRM_LIMITS = {
|
|
13
|
+
subjectChars: 120,
|
|
14
|
+
messageChars: 600,
|
|
15
|
+
};
|
|
16
|
+
export const confirmEmailSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
/** A `string` field of the same collection: the visitor's address. */
|
|
19
|
+
field: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
20
|
+
subject: z.string().min(1).max(CONFIRM_LIMITS.subjectChars).optional(),
|
|
21
|
+
/** Plain text; a blank line starts a new paragraph. */
|
|
22
|
+
message: z.string().min(1).max(CONFIRM_LIMITS.messageChars).optional(),
|
|
23
|
+
/** One button back to the public page the visitor answered. */
|
|
24
|
+
link: z.boolean().optional(),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
/** A link, an address a client would turn into one, or markup. */
|
|
28
|
+
const NOT_PLAIN = /(?:[a-z][a-z0-9+.-]*:\/\/|www\.|[<>]|\]\(|mailto:)/i;
|
|
29
|
+
export function confirmEmailProblems(collection, declared) {
|
|
30
|
+
const confirm = declared.confirmEmail;
|
|
31
|
+
if (!confirm)
|
|
32
|
+
return [];
|
|
33
|
+
const problems = [];
|
|
34
|
+
if (!declared.publicSubmit) {
|
|
35
|
+
problems.push({
|
|
36
|
+
code: 'data_confirm_submit',
|
|
37
|
+
collection,
|
|
38
|
+
message: `${collection} sends a confirmation email but visitors can't submit to it: mark it publicSubmit.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const raw = declared.schema[confirm.field];
|
|
42
|
+
let kind = null;
|
|
43
|
+
try {
|
|
44
|
+
kind = raw === undefined ? null : parseFieldType(raw).kind;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
kind = null;
|
|
48
|
+
}
|
|
49
|
+
if (kind !== 'string') {
|
|
50
|
+
problems.push({
|
|
51
|
+
code: 'data_confirm_field',
|
|
52
|
+
collection,
|
|
53
|
+
field: confirm.field,
|
|
54
|
+
message: `${collection}.confirmEmail.field must name one of its string fields; ${confirm.field} is not.`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const [key, text] of [
|
|
58
|
+
['subject', confirm.subject],
|
|
59
|
+
['message', confirm.message],
|
|
60
|
+
]) {
|
|
61
|
+
if (text !== undefined && NOT_PLAIN.test(text)) {
|
|
62
|
+
problems.push({
|
|
63
|
+
code: 'data_confirm_text',
|
|
64
|
+
collection,
|
|
65
|
+
message: `${collection}.confirmEmail.${key} is plain text: no links, addresses or markup.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
export function confirmEmailSpec(declared) {
|
|
72
|
+
return {
|
|
73
|
+
field: declared.field,
|
|
74
|
+
subject: declared.subject?.trim() || null,
|
|
75
|
+
message: declared.message?.trim() || null,
|
|
76
|
+
link: declared.link === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/define.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* name: 'issues',
|
|
12
12
|
* version: '0.1.0',
|
|
13
13
|
* screens: { board: { entry: 'screens/board.js' } },
|
|
14
|
-
* placements: [{ kind: 'project-
|
|
14
|
+
* placements: [{ kind: 'project-widget', screen: 'bord', sizes: ['large'] }], // error: "bord" is not a screen
|
|
15
15
|
* });
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
package/src/define.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* name: 'issues',
|
|
12
12
|
* version: '0.1.0',
|
|
13
13
|
* screens: { board: { entry: 'screens/board.js' } },
|
|
14
|
-
* placements: [{ kind: 'project-
|
|
14
|
+
* placements: [{ kind: 'project-widget', screen: 'bord', sizes: ['large'] }], // error: "bord" is not a screen
|
|
15
15
|
* });
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
package/src/field-types.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type ZodType } from 'zod';
|
|
|
12
12
|
* and everything downstream — the store, the generated tools, the screen's
|
|
13
13
|
* form — reads that shape, never the manifest's spelling.
|
|
14
14
|
*/
|
|
15
|
-
export type FieldKind = 'string' | 'text' | 'enum' | 'member' | 'project' | 'date' | 'number' | 'boolean' | 'string[]' | 'token';
|
|
15
|
+
export type FieldKind = 'string' | 'text' | 'enum' | 'member' | 'project' | 'date' | 'number' | 'boolean' | 'string[]' | 'token' | 'canvas' | 'ref';
|
|
16
16
|
export interface FieldType {
|
|
17
17
|
kind: FieldKind;
|
|
18
18
|
/** Written with a trailing `?`: may be left out on create. */
|
|
@@ -27,6 +27,25 @@ export interface FieldType {
|
|
|
27
27
|
* changes, and a value with no label reads as itself.
|
|
28
28
|
*/
|
|
29
29
|
labels?: Readonly<Record<string, string>>;
|
|
30
|
+
/**
|
|
31
|
+
* Written by several people at once (`tasks/docs-wiki` DW01): a `text`
|
|
32
|
+
* field drawn with `bry-rich-text bind` is one fragment of a shared Yjs
|
|
33
|
+
* document the host keeps. Such a field leaves the record's `version`
|
|
34
|
+
* check, and a tool's change to it merges into the live text.
|
|
35
|
+
*/
|
|
36
|
+
coedit?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Kept in the plain `fields` column although its kind is not (P5): an
|
|
39
|
+
* open-schema table's multi-select and link lists, and the field naming a
|
|
40
|
+
* row's table. Set by the host only, never parsed from a manifest.
|
|
41
|
+
*/
|
|
42
|
+
plain?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The most entries a `string[]` holds, when not `FIELD_LIMITS.listEntries`:
|
|
45
|
+
* a collection's readers or editors field takes `READERS_LIMIT` (P16,
|
|
46
|
+
* DW06). Set by the host only, never parsed from a manifest.
|
|
47
|
+
*/
|
|
48
|
+
maxEntries?: number;
|
|
30
49
|
}
|
|
31
50
|
/**
|
|
32
51
|
* The bounds a schema and a record are held to (A3-F01-S03).
|
|
@@ -74,9 +93,13 @@ export declare const FIELD_NAME: RegExp;
|
|
|
74
93
|
export declare const COLLECTION_NAME: RegExp;
|
|
75
94
|
/** A field type the manifest wrote that Brydio does not have, or a bad list. */
|
|
76
95
|
export declare class FieldTypeInvalid extends Error {
|
|
77
|
-
readonly code: 'data_field_type_unknown' | 'data_enum_empty' | 'data_enum_too_many' | 'data_enum_value_invalid' | 'data_enum_duplicate' | 'data_default_not_allowed' | 'data_default_on_required' | 'data_default_invalid' | 'data_labels_not_allowed' | 'data_label_invalid' | 'data_label_unknown_value' | 'data_field_key_unknown';
|
|
78
|
-
constructor(code: 'data_field_type_unknown' | 'data_enum_empty' | 'data_enum_too_many' | 'data_enum_value_invalid' | 'data_enum_duplicate' | 'data_default_not_allowed' | 'data_default_on_required' | 'data_default_invalid' | 'data_labels_not_allowed' | 'data_label_invalid' | 'data_label_unknown_value' | 'data_field_key_unknown', message: string);
|
|
96
|
+
readonly code: 'data_field_type_unknown' | 'data_enum_empty' | 'data_enum_too_many' | 'data_enum_value_invalid' | 'data_enum_duplicate' | 'data_default_not_allowed' | 'data_default_on_required' | 'data_default_invalid' | 'data_labels_not_allowed' | 'data_label_invalid' | 'data_label_unknown_value' | 'data_field_key_unknown' | 'data_coedit_not_text';
|
|
97
|
+
constructor(code: 'data_field_type_unknown' | 'data_enum_empty' | 'data_enum_too_many' | 'data_enum_value_invalid' | 'data_enum_duplicate' | 'data_default_not_allowed' | 'data_default_on_required' | 'data_default_invalid' | 'data_labels_not_allowed' | 'data_label_invalid' | 'data_label_unknown_value' | 'data_field_key_unknown' | 'data_coedit_not_text', message: string);
|
|
79
98
|
}
|
|
99
|
+
/** The longest record id a `ref` field holds. */
|
|
100
|
+
export declare const REF_CHARS = 128;
|
|
101
|
+
/** What a record id looks like: letters, digits, `_` and `-`; never words. */
|
|
102
|
+
export declare const REF_FORMAT: RegExp;
|
|
80
103
|
/**
|
|
81
104
|
* One field's manifest spelling, as a type.
|
|
82
105
|
*
|
|
@@ -87,6 +110,16 @@ export declare class FieldTypeInvalid extends Error {
|
|
|
87
110
|
export declare function parseFieldType(raw: unknown): FieldType;
|
|
88
111
|
/** How long a choice value's label may be. */
|
|
89
112
|
export declare const LABEL_CHARS = 60;
|
|
113
|
+
/** True for a field several people write at once, through the co-editing layer. */
|
|
114
|
+
export declare const isCoedited: (type: FieldType) => boolean;
|
|
115
|
+
/**
|
|
116
|
+
* What a drawing's field holds in the record (WB01): a summary the host writes
|
|
117
|
+
* from the live board, so a list, search and the assistant can say what is on
|
|
118
|
+
* it. The drawing itself lives only in the co-edited document.
|
|
119
|
+
*/
|
|
120
|
+
export declare const CANVAS_SUMMARY_CHARS = 10000;
|
|
121
|
+
/** A collection's co-edited fields, by name. */
|
|
122
|
+
export declare const coeditedFields: (fields: Readonly<Record<string, FieldType>>) => string[];
|
|
90
123
|
/** How a choice's value reads to a person: its label, or the value itself. */
|
|
91
124
|
export declare const labelOfValue: (type: FieldType, value: string) => string;
|
|
92
125
|
/** True for a type whose value lands in the plain `fields` column. */
|
|
@@ -127,7 +160,10 @@ type Optional<T> = T extends `${string}?` ? true : T extends {
|
|
|
127
160
|
/** The TypeScript type of one field's value, from its manifest spelling. */
|
|
128
161
|
export type FieldValue<T> = T extends {
|
|
129
162
|
type: infer U;
|
|
130
|
-
} ? FieldValue<U> : T extends readonly (infer V)[] ? V : T extends 'number' | 'number?' ? number : T extends 'boolean' | 'boolean?' ? boolean : T extends 'string[]' | 'string[]?' ? string[] : T extends 'token' | 'token?' ? (typeof COLOUR_TOKENS)[number] : T extends
|
|
163
|
+
} ? FieldValue<U> : T extends readonly (infer V)[] ? V : T extends 'number' | 'number?' ? number : T extends 'boolean' | 'boolean?' ? boolean : T extends 'string[]' | 'string[]?' ? string[] : T extends 'token' | 'token?' ? (typeof COLOUR_TOKENS)[number] : T extends 'canvas' | 'canvas?' ? {
|
|
164
|
+
elements: number;
|
|
165
|
+
text: string;
|
|
166
|
+
} : T extends string ? string : never;
|
|
131
167
|
type Simplify<T> = {
|
|
132
168
|
[K in keyof T]: T[K];
|
|
133
169
|
} & {};
|
package/src/field-types.js
CHANGED
|
@@ -65,10 +65,12 @@ const STRUCTURED = new Set([
|
|
|
65
65
|
'number',
|
|
66
66
|
'boolean',
|
|
67
67
|
'token',
|
|
68
|
+
// Another of the app's records, by id (DW06): an id, never words, so a list can name it.
|
|
69
|
+
'ref',
|
|
68
70
|
]);
|
|
69
71
|
/** Only the words in these may one day be searched (A3-F01-S02). */
|
|
70
72
|
const SEARCHABLE = new Set(['string', 'text', 'string[]']);
|
|
71
|
-
const SCALARS = new Set(['string', 'text', 'member', 'project', 'date', 'number', 'boolean', 'token']);
|
|
73
|
+
const SCALARS = new Set(['string', 'text', 'member', 'project', 'date', 'number', 'boolean', 'token', 'canvas', 'ref']);
|
|
72
74
|
/** A field type the manifest wrote that Brydio does not have, or a bad list. */
|
|
73
75
|
export class FieldTypeInvalid extends Error {
|
|
74
76
|
code;
|
|
@@ -78,6 +80,10 @@ export class FieldTypeInvalid extends Error {
|
|
|
78
80
|
this.name = 'FieldTypeInvalid';
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
/** The longest record id a `ref` field holds. */
|
|
84
|
+
export const REF_CHARS = 128;
|
|
85
|
+
/** What a record id looks like: letters, digits, `_` and `-`; never words. */
|
|
86
|
+
export const REF_FORMAT = /^[A-Za-z0-9_-]+$/;
|
|
81
87
|
/**
|
|
82
88
|
* One field's manifest spelling, as a type.
|
|
83
89
|
*
|
|
@@ -99,18 +105,24 @@ export function parseFieldType(raw) {
|
|
|
99
105
|
return { kind: 'string[]', optional };
|
|
100
106
|
if (name === 'token')
|
|
101
107
|
return { kind: 'token', optional, values: COLOUR_TOKENS };
|
|
108
|
+
// A drawing is always co-edited and never required: it starts empty (WB01).
|
|
109
|
+
if (name === 'canvas')
|
|
110
|
+
return { kind: 'canvas', optional: true, coedit: true };
|
|
102
111
|
if (SCALARS.has(name))
|
|
103
112
|
return { kind: name, optional };
|
|
104
113
|
throw new FieldTypeInvalid('data_field_type_unknown', `"${raw}" is not a field type.`);
|
|
105
114
|
}
|
|
106
|
-
const FIELD_KEYS = new Set(['type', 'optional', 'default', 'labels']);
|
|
115
|
+
const FIELD_KEYS = new Set(['type', 'optional', 'default', 'labels', 'coedit']);
|
|
107
116
|
/** How long a choice value's label may be. */
|
|
108
117
|
export const LABEL_CHARS = 60;
|
|
109
118
|
/** The object form: the short form under `type`, and what it may add. */
|
|
110
119
|
function parseFieldObject(raw) {
|
|
111
120
|
const unknown = Object.keys(raw).find(key => !FIELD_KEYS.has(key));
|
|
112
121
|
if (unknown) {
|
|
113
|
-
throw new FieldTypeInvalid('data_field_key_unknown', `"${unknown}" is not something a field may say; use type, optional, default and
|
|
122
|
+
throw new FieldTypeInvalid('data_field_key_unknown', `"${unknown}" is not something a field may say; use type, optional, default, labels and coedit.`);
|
|
123
|
+
}
|
|
124
|
+
if (raw.coedit !== undefined && typeof raw.coedit !== 'boolean') {
|
|
125
|
+
throw new FieldTypeInvalid('data_field_key_unknown', '"coedit" is true or false.');
|
|
114
126
|
}
|
|
115
127
|
if (typeof raw.type !== 'string' && !Array.isArray(raw.type)) {
|
|
116
128
|
throw new FieldTypeInvalid('data_field_type_unknown', 'A field written as an object names its type under "type".');
|
|
@@ -119,7 +131,12 @@ function parseFieldObject(raw) {
|
|
|
119
131
|
throw new FieldTypeInvalid('data_field_key_unknown', '"optional" is true or false.');
|
|
120
132
|
}
|
|
121
133
|
const inner = parseFieldType(raw.type);
|
|
122
|
-
const type = {
|
|
134
|
+
const type = {
|
|
135
|
+
...inner,
|
|
136
|
+
optional: inner.optional || raw.optional === true,
|
|
137
|
+
...labelsOf(inner, raw),
|
|
138
|
+
...coeditOf(inner, raw),
|
|
139
|
+
};
|
|
123
140
|
if (!('default' in raw))
|
|
124
141
|
return type;
|
|
125
142
|
const value = raw.default;
|
|
@@ -157,6 +174,27 @@ function labelsOf(type, raw) {
|
|
|
157
174
|
}
|
|
158
175
|
return { labels: { ...labels } };
|
|
159
176
|
}
|
|
177
|
+
/** `coedit`, on long text only: nothing else is typed into by several people at once. */
|
|
178
|
+
function coeditOf(type, raw) {
|
|
179
|
+
if (raw.coedit !== true)
|
|
180
|
+
return {};
|
|
181
|
+
if (type.kind !== 'text' && type.kind !== 'canvas') {
|
|
182
|
+
throw new FieldTypeInvalid('data_coedit_not_text', `Only a text or canvas field may be co-edited, not a ${type.kind}.`);
|
|
183
|
+
}
|
|
184
|
+
return { coedit: true };
|
|
185
|
+
}
|
|
186
|
+
/** True for a field several people write at once, through the co-editing layer. */
|
|
187
|
+
export const isCoedited = (type) => type.kind === 'canvas' || (type.kind === 'text' && type.coedit === true);
|
|
188
|
+
/**
|
|
189
|
+
* What a drawing's field holds in the record (WB01): a summary the host writes
|
|
190
|
+
* from the live board, so a list, search and the assistant can say what is on
|
|
191
|
+
* it. The drawing itself lives only in the co-edited document.
|
|
192
|
+
*/
|
|
193
|
+
export const CANVAS_SUMMARY_CHARS = 10_000;
|
|
194
|
+
/** A collection's co-edited fields, by name. */
|
|
195
|
+
export const coeditedFields = (fields) => Object.entries(fields)
|
|
196
|
+
.filter(([, type]) => isCoedited(type))
|
|
197
|
+
.map(([field]) => field);
|
|
160
198
|
/** How a choice's value reads to a person: its label, or the value itself. */
|
|
161
199
|
export const labelOfValue = (type, value) => type.labels?.[value] ?? value;
|
|
162
200
|
function parseEnumeration(values) {
|
|
@@ -177,9 +215,9 @@ function parseEnumeration(values) {
|
|
|
177
215
|
return { kind: 'enum', optional: false, values: values };
|
|
178
216
|
}
|
|
179
217
|
/** True for a type whose value lands in the plain `fields` column. */
|
|
180
|
-
export const isStructured = (type) => STRUCTURED.has(type.kind);
|
|
218
|
+
export const isStructured = (type) => STRUCTURED.has(type.kind) || type.plain === true;
|
|
181
219
|
/** True for a type a list can be ordered by: structured, and one value. */
|
|
182
|
-
export const isSortable = (type) => STRUCTURED.has(type.kind);
|
|
220
|
+
export const isSortable = (type) => STRUCTURED.has(type.kind) || (type.plain === true && type.kind !== 'string[]');
|
|
183
221
|
export const isSearchable = (type) => SEARCHABLE.has(type.kind);
|
|
184
222
|
/**
|
|
185
223
|
* The names of a schema's structured fields: the ones kept in plain beside the
|
|
@@ -227,7 +265,11 @@ export function valueSchema(type) {
|
|
|
227
265
|
case 'boolean':
|
|
228
266
|
return z.boolean();
|
|
229
267
|
case 'string[]':
|
|
230
|
-
return z.array(z.string().max(FIELD_LIMITS.stringChars)).max(FIELD_LIMITS.listEntries);
|
|
268
|
+
return z.array(z.string().max(FIELD_LIMITS.stringChars)).max(type.maxEntries ?? FIELD_LIMITS.listEntries);
|
|
269
|
+
case 'canvas':
|
|
270
|
+
return z.object({ elements: z.number().int().min(0), text: z.string().max(CANVAS_SUMMARY_CHARS) }).strict();
|
|
271
|
+
case 'ref':
|
|
272
|
+
return z.string().min(1).max(REF_CHARS).regex(REF_FORMAT);
|
|
231
273
|
}
|
|
232
274
|
}
|
|
233
275
|
/**
|
|
@@ -260,9 +302,13 @@ export function valueProblem(field, type, value) {
|
|
|
260
302
|
case 'boolean':
|
|
261
303
|
return `${field} must be true or false.`;
|
|
262
304
|
case 'string[]':
|
|
263
|
-
return Array.isArray(value) && value.length > FIELD_LIMITS.listEntries
|
|
264
|
-
? `${field} may hold at most ${FIELD_LIMITS.listEntries} entries.`
|
|
305
|
+
return Array.isArray(value) && value.length > (type.maxEntries ?? FIELD_LIMITS.listEntries)
|
|
306
|
+
? `${field} may hold at most ${type.maxEntries ?? FIELD_LIMITS.listEntries} entries.`
|
|
265
307
|
: `${field} must be a list of short texts, each at most ${FIELD_LIMITS.stringChars.toLocaleString('en-GB')} characters.`;
|
|
308
|
+
case 'canvas':
|
|
309
|
+
return `${field} is a drawing: it is changed on its board, never written.`;
|
|
310
|
+
case 'ref':
|
|
311
|
+
return `${field} must be the id of one of this app's records.`;
|
|
266
312
|
}
|
|
267
313
|
}
|
|
268
314
|
/** A type in a few words, for a tool's description: every allowed value named. */
|
|
@@ -288,6 +334,10 @@ export function describeType(type) {
|
|
|
288
334
|
return 'true or false';
|
|
289
335
|
case 'string[]':
|
|
290
336
|
return 'a list of short texts';
|
|
337
|
+
case 'canvas':
|
|
338
|
+
return 'a drawing, read only: how many shapes it has and the words on it';
|
|
339
|
+
case 'ref':
|
|
340
|
+
return "the id of another of this app's records";
|
|
291
341
|
}
|
|
292
342
|
}
|
|
293
343
|
/** ", read as \"todo\" is To do, \"done\" is Done", for a choice with labels. */
|