@conform-ed/contracts 0.0.8 → 0.0.9
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/.turbo/turbo-format.log +5 -6
- package/.turbo/turbo-lint.log +3 -4
- package/.turbo/turbo-test.log +223 -197
- package/.turbo/turbo-typecheck.log +1 -2
- package/h5p-v1-zod-templates.md +124 -0
- package/package.json +3 -2
- package/src/h5p/v1/content.ts +44 -0
- package/src/h5p/v1/h5p-json.ts +67 -0
- package/src/h5p/v1/index.ts +109 -0
- package/src/h5p/v1/library-json.ts +70 -0
- package/src/h5p/v1/semantics.ts +268 -0
- package/src/h5p/v1/shared.ts +56 -0
- package/src/index.ts +2 -0
- package/test/h5p-v1.test.ts +330 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Conditional visibility rule for a field — renders/hides based on another field's value.
|
|
4
|
+
const showWhenRuleSchema = z.object({
|
|
5
|
+
field: z.string(),
|
|
6
|
+
equals: z.union([z.string(), z.boolean(), z.number(), z.array(z.string())]),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const showWhenSchema = z.object({
|
|
10
|
+
type: z.enum(["and", "or"]).optional(),
|
|
11
|
+
rules: z.array(showWhenRuleSchema),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Shared optional base properties present on all field types.
|
|
15
|
+
const baseFieldShape = {
|
|
16
|
+
name: z.string().min(1),
|
|
17
|
+
label: z.string().optional(),
|
|
18
|
+
description: z.string().optional(),
|
|
19
|
+
importance: z.enum(["high", "medium", "low"]).optional(),
|
|
20
|
+
optional: z.boolean().optional(),
|
|
21
|
+
common: z.boolean().optional(),
|
|
22
|
+
default: z.unknown().optional(),
|
|
23
|
+
widget: z.string().optional(),
|
|
24
|
+
showWhen: showWhenSchema.optional(),
|
|
25
|
+
// When field is hidden by showWhen, reset value to null
|
|
26
|
+
nullWhenHidden: z.boolean().optional(),
|
|
27
|
+
// Override default copy/paste behaviour in editor
|
|
28
|
+
copy: z.boolean().optional(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// TypeScript-side recursive types must be declared manually to avoid circular inference.
|
|
32
|
+
export interface H5pFieldBase {
|
|
33
|
+
name: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
importance?: "high" | "medium" | "low";
|
|
37
|
+
optional?: boolean;
|
|
38
|
+
common?: boolean;
|
|
39
|
+
default?: unknown;
|
|
40
|
+
widget?: string;
|
|
41
|
+
showWhen?: { type?: "and" | "or"; rules: Array<{ field: string; equals: unknown }> };
|
|
42
|
+
nullWhenHidden?: boolean;
|
|
43
|
+
copy?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface H5pTextField extends H5pFieldBase {
|
|
47
|
+
type: "text";
|
|
48
|
+
maxLength?: number;
|
|
49
|
+
minLength?: number;
|
|
50
|
+
regexp?: { pattern: string; modifiers?: string; description?: string };
|
|
51
|
+
placeholder?: string;
|
|
52
|
+
enterMode?: string;
|
|
53
|
+
tags?: string[];
|
|
54
|
+
font?: unknown;
|
|
55
|
+
}
|
|
56
|
+
export interface H5pHtmlField extends H5pFieldBase {
|
|
57
|
+
type: "html";
|
|
58
|
+
tags?: string[];
|
|
59
|
+
maxLength?: number;
|
|
60
|
+
minLength?: number;
|
|
61
|
+
placeholder?: string;
|
|
62
|
+
font?: unknown;
|
|
63
|
+
}
|
|
64
|
+
export interface H5pNumberField extends H5pFieldBase {
|
|
65
|
+
type: "number";
|
|
66
|
+
min?: number;
|
|
67
|
+
max?: number;
|
|
68
|
+
steps?: number;
|
|
69
|
+
decimals?: number;
|
|
70
|
+
}
|
|
71
|
+
export interface H5pBooleanField extends H5pFieldBase {
|
|
72
|
+
type: "boolean";
|
|
73
|
+
}
|
|
74
|
+
export interface H5pImageField extends H5pFieldBase {
|
|
75
|
+
type: "image";
|
|
76
|
+
allowedMimeTypes?: string[];
|
|
77
|
+
disableCopyright?: boolean;
|
|
78
|
+
}
|
|
79
|
+
export interface H5pAudioField extends H5pFieldBase {
|
|
80
|
+
type: "audio";
|
|
81
|
+
allowedMimeTypes?: string[];
|
|
82
|
+
disableConversion?: boolean;
|
|
83
|
+
}
|
|
84
|
+
export interface H5pVideoField extends H5pFieldBase {
|
|
85
|
+
type: "video";
|
|
86
|
+
allowedMimeTypes?: string[];
|
|
87
|
+
disableConversion?: boolean;
|
|
88
|
+
}
|
|
89
|
+
export interface H5pFileField extends H5pFieldBase {
|
|
90
|
+
type: "file";
|
|
91
|
+
allowedMimeTypes?: string[];
|
|
92
|
+
}
|
|
93
|
+
export interface H5pSelectField extends H5pFieldBase {
|
|
94
|
+
type: "select";
|
|
95
|
+
options: Array<{ value: string; label: string }>;
|
|
96
|
+
multiple?: boolean;
|
|
97
|
+
}
|
|
98
|
+
export interface H5pLibraryField extends H5pFieldBase {
|
|
99
|
+
type: "library";
|
|
100
|
+
options?: string[];
|
|
101
|
+
}
|
|
102
|
+
export interface H5pGroupField extends H5pFieldBase {
|
|
103
|
+
type: "group";
|
|
104
|
+
fields: H5pSemanticsField[];
|
|
105
|
+
isSubContent?: boolean;
|
|
106
|
+
expanded?: boolean;
|
|
107
|
+
}
|
|
108
|
+
export interface H5pListField extends H5pFieldBase {
|
|
109
|
+
type: "list";
|
|
110
|
+
field: H5pSemanticsField;
|
|
111
|
+
min?: number;
|
|
112
|
+
max?: number;
|
|
113
|
+
entity?: string;
|
|
114
|
+
widgets?: Array<{ name: string; label?: string }>;
|
|
115
|
+
}
|
|
116
|
+
export interface H5pTableField extends H5pFieldBase {
|
|
117
|
+
type: "table";
|
|
118
|
+
columns: H5pSemanticsField[];
|
|
119
|
+
rows?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type H5pSemanticsField =
|
|
123
|
+
| H5pTextField
|
|
124
|
+
| H5pHtmlField
|
|
125
|
+
| H5pNumberField
|
|
126
|
+
| H5pBooleanField
|
|
127
|
+
| H5pImageField
|
|
128
|
+
| H5pAudioField
|
|
129
|
+
| H5pVideoField
|
|
130
|
+
| H5pFileField
|
|
131
|
+
| H5pSelectField
|
|
132
|
+
| H5pLibraryField
|
|
133
|
+
| H5pGroupField
|
|
134
|
+
| H5pListField
|
|
135
|
+
| H5pTableField;
|
|
136
|
+
|
|
137
|
+
// --- Zod schemas ---
|
|
138
|
+
|
|
139
|
+
// Leaf field schemas (no recursion)
|
|
140
|
+
const textFieldSchema = z.object({
|
|
141
|
+
...baseFieldShape,
|
|
142
|
+
type: z.literal("text"),
|
|
143
|
+
maxLength: z.number().int().positive().optional(),
|
|
144
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
145
|
+
regexp: z
|
|
146
|
+
.object({ pattern: z.string(), modifiers: z.string().optional(), description: z.string().optional() })
|
|
147
|
+
.optional(),
|
|
148
|
+
placeholder: z.string().optional(),
|
|
149
|
+
enterMode: z.string().optional(),
|
|
150
|
+
tags: z.array(z.string()).optional(),
|
|
151
|
+
font: z.unknown().optional(),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const htmlFieldSchema = z.object({
|
|
155
|
+
...baseFieldShape,
|
|
156
|
+
type: z.literal("html"),
|
|
157
|
+
tags: z.array(z.string()).optional(),
|
|
158
|
+
maxLength: z.number().int().positive().optional(),
|
|
159
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
160
|
+
placeholder: z.string().optional(),
|
|
161
|
+
font: z.unknown().optional(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const numberFieldSchema = z.object({
|
|
165
|
+
...baseFieldShape,
|
|
166
|
+
type: z.literal("number"),
|
|
167
|
+
min: z.number().optional(),
|
|
168
|
+
max: z.number().optional(),
|
|
169
|
+
steps: z.number().positive().optional(),
|
|
170
|
+
decimals: z.number().int().nonnegative().optional(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const booleanFieldSchema = z.object({
|
|
174
|
+
...baseFieldShape,
|
|
175
|
+
type: z.literal("boolean"),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const imageFieldSchema = z.object({
|
|
179
|
+
...baseFieldShape,
|
|
180
|
+
type: z.literal("image"),
|
|
181
|
+
allowedMimeTypes: z.array(z.string()).optional(),
|
|
182
|
+
disableCopyright: z.boolean().optional(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const audioFieldSchema = z.object({
|
|
186
|
+
...baseFieldShape,
|
|
187
|
+
type: z.literal("audio"),
|
|
188
|
+
allowedMimeTypes: z.array(z.string()).optional(),
|
|
189
|
+
disableConversion: z.boolean().optional(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const videoFieldSchema = z.object({
|
|
193
|
+
...baseFieldShape,
|
|
194
|
+
type: z.literal("video"),
|
|
195
|
+
allowedMimeTypes: z.array(z.string()).optional(),
|
|
196
|
+
disableConversion: z.boolean().optional(),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const fileFieldSchema = z.object({
|
|
200
|
+
...baseFieldShape,
|
|
201
|
+
type: z.literal("file"),
|
|
202
|
+
allowedMimeTypes: z.array(z.string()).optional(),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const selectFieldSchema = z.object({
|
|
206
|
+
...baseFieldShape,
|
|
207
|
+
type: z.literal("select"),
|
|
208
|
+
options: z.array(z.object({ value: z.string(), label: z.string() })),
|
|
209
|
+
multiple: z.boolean().optional(),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const libraryFieldSchema = z.object({
|
|
213
|
+
...baseFieldShape,
|
|
214
|
+
type: z.literal("library"),
|
|
215
|
+
options: z.array(z.string()).optional(),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Recursive field schemas — use z.lazy so they can reference the outer union.
|
|
219
|
+
// Using let-then-assign pattern (same as cmi5 Block/Au schemas).
|
|
220
|
+
let h5pSemanticsFieldSchemaInternal: z.ZodType<H5pSemanticsField>;
|
|
221
|
+
|
|
222
|
+
const groupFieldSchema: z.ZodType<H5pGroupField> = z.object({
|
|
223
|
+
...baseFieldShape,
|
|
224
|
+
type: z.literal("group"),
|
|
225
|
+
fields: z.lazy(() => z.array(h5pSemanticsFieldSchemaInternal)),
|
|
226
|
+
isSubContent: z.boolean().optional(),
|
|
227
|
+
expanded: z.boolean().optional(),
|
|
228
|
+
}) as z.ZodType<H5pGroupField>;
|
|
229
|
+
|
|
230
|
+
const listFieldSchema: z.ZodType<H5pListField> = z.object({
|
|
231
|
+
...baseFieldShape,
|
|
232
|
+
type: z.literal("list"),
|
|
233
|
+
field: z.lazy(() => h5pSemanticsFieldSchemaInternal),
|
|
234
|
+
min: z.number().int().nonnegative().optional(),
|
|
235
|
+
max: z.number().int().positive().optional(),
|
|
236
|
+
entity: z.string().optional(),
|
|
237
|
+
widgets: z.array(z.object({ name: z.string(), label: z.string().optional() })).optional(),
|
|
238
|
+
}) as z.ZodType<H5pListField>;
|
|
239
|
+
|
|
240
|
+
const tableFieldSchema: z.ZodType<H5pTableField> = z.object({
|
|
241
|
+
...baseFieldShape,
|
|
242
|
+
type: z.literal("table"),
|
|
243
|
+
columns: z.lazy(() => z.array(h5pSemanticsFieldSchemaInternal)),
|
|
244
|
+
rows: z.number().int().positive().optional(),
|
|
245
|
+
}) as z.ZodType<H5pTableField>;
|
|
246
|
+
|
|
247
|
+
h5pSemanticsFieldSchemaInternal = z.union([
|
|
248
|
+
textFieldSchema,
|
|
249
|
+
htmlFieldSchema,
|
|
250
|
+
numberFieldSchema,
|
|
251
|
+
booleanFieldSchema,
|
|
252
|
+
imageFieldSchema,
|
|
253
|
+
audioFieldSchema,
|
|
254
|
+
videoFieldSchema,
|
|
255
|
+
fileFieldSchema,
|
|
256
|
+
selectFieldSchema,
|
|
257
|
+
libraryFieldSchema,
|
|
258
|
+
groupFieldSchema,
|
|
259
|
+
listFieldSchema,
|
|
260
|
+
tableFieldSchema,
|
|
261
|
+
]) as z.ZodType<H5pSemanticsField>;
|
|
262
|
+
|
|
263
|
+
// The schema for a single semantics field (any type).
|
|
264
|
+
export const H5pSemanticsFieldSchema: z.ZodType<H5pSemanticsField> = h5pSemanticsFieldSchemaInternal;
|
|
265
|
+
|
|
266
|
+
// The top-level semantics.json document — an array of field definitions.
|
|
267
|
+
export const H5pSemanticsSchema = z.array(H5pSemanticsFieldSchema);
|
|
268
|
+
export type H5pSemantics = z.infer<typeof H5pSemanticsSchema>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export function strictObject<T extends z.ZodRawShape>(shape: T) {
|
|
4
|
+
return z.object(shape).strict();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Machine name: alphanumeric, hyphens, dots — used in machineName fields and folder names.
|
|
8
|
+
// Pattern from h5p-php-library H5PValidator::isValidRequiredH5pData
|
|
9
|
+
export const H5pMachineNameSchema = z.string().regex(/^[\w0-9-.]{1,255}$/iu);
|
|
10
|
+
|
|
11
|
+
// Library folder name without patch: "H5P.Image-1.1", "H5P.Column-1.22"
|
|
12
|
+
// Patch version is intentionally excluded from folder names and dependency references per spec.
|
|
13
|
+
export const H5pLibraryFolderNameSchema = z.string().regex(/^[\w0-9-.]{1,255}-\d{1,5}\.\d{1,5}$/u);
|
|
14
|
+
|
|
15
|
+
// Version reference used in preloadedDependencies / dynamicDependencies / editorDependencies.
|
|
16
|
+
// Patch is intentionally omitted — any patch of the specified major.minor is acceptable.
|
|
17
|
+
export const H5pVersionRefSchema = strictObject({
|
|
18
|
+
machineName: H5pMachineNameSchema,
|
|
19
|
+
majorVersion: z.number().int().nonnegative().max(99999),
|
|
20
|
+
minorVersion: z.number().int().nonnegative().max(99999),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Standard H5P license codes as used in h5p.json and library.json.
|
|
24
|
+
// "U" = undisclosed/unknown (H5P default when no license is specified).
|
|
25
|
+
export const H5pLicenseSchema = z.enum([
|
|
26
|
+
"CC BY",
|
|
27
|
+
"CC BY-SA",
|
|
28
|
+
"CC BY-NC",
|
|
29
|
+
"CC BY-NC-SA",
|
|
30
|
+
"CC BY-ND",
|
|
31
|
+
"CC BY-NC-ND",
|
|
32
|
+
"CC0",
|
|
33
|
+
"GNU GPL",
|
|
34
|
+
"PD",
|
|
35
|
+
"ODC PDDL",
|
|
36
|
+
"CC PDM",
|
|
37
|
+
"U",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export const H5pAuthorSchema = strictObject({
|
|
41
|
+
name: z.string().min(1),
|
|
42
|
+
role: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const H5pChangelogEntrySchema = strictObject({
|
|
46
|
+
date: z.string(),
|
|
47
|
+
changes: z.array(z.string()),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Inferred types
|
|
51
|
+
export type H5pMachineName = z.infer<typeof H5pMachineNameSchema>;
|
|
52
|
+
export type H5pLibraryFolderName = z.infer<typeof H5pLibraryFolderNameSchema>;
|
|
53
|
+
export type H5pVersionRef = z.infer<typeof H5pVersionRefSchema>;
|
|
54
|
+
export type H5pLicense = z.infer<typeof H5pLicenseSchema>;
|
|
55
|
+
export type H5pAuthor = z.infer<typeof H5pAuthorSchema>;
|
|
56
|
+
export type H5pChangelogEntry = z.infer<typeof H5pChangelogEntrySchema>;
|
package/src/index.ts
CHANGED
|
@@ -30,3 +30,5 @@ export * as ClrV2_0 from "./clr/v2_0";
|
|
|
30
30
|
export * as OpenBadgesV3_0 from "./open-badges/v3_0";
|
|
31
31
|
export * as OneRosterV1_2 from "./oneroster/v1_2";
|
|
32
32
|
export * as VcDataModelV2_0 from "./vc-data-model/v2_0";
|
|
33
|
+
export * as H5pV1 from "./h5p/v1";
|
|
34
|
+
export { H5pV1DerivedZodTemplates } from "./h5p/v1";
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { H5pV1 } from "@conform-ed/contracts";
|
|
3
|
+
|
|
4
|
+
// --- PackageManifest (h5p.json) ---
|
|
5
|
+
|
|
6
|
+
test("H5P PackageManifest accepts a minimal valid h5p.json", () => {
|
|
7
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
8
|
+
title: "My H5P Content",
|
|
9
|
+
language: "en",
|
|
10
|
+
machineName: "H5P.CoursePresentation",
|
|
11
|
+
mainLibrary: "H5P.CoursePresentation",
|
|
12
|
+
preloadedDependencies: [{ machineName: "H5P.CoursePresentation", majorVersion: 1, minorVersion: 27 }],
|
|
13
|
+
embedTypes: ["iframe"],
|
|
14
|
+
});
|
|
15
|
+
expect(result.success).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("H5P PackageManifest accepts all optional fields", () => {
|
|
19
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
20
|
+
title: "Interactive Video",
|
|
21
|
+
language: "en-US",
|
|
22
|
+
machineName: "H5P.InteractiveVideo",
|
|
23
|
+
mainLibrary: "H5P.InteractiveVideo",
|
|
24
|
+
preloadedDependencies: [
|
|
25
|
+
{ machineName: "H5P.InteractiveVideo", majorVersion: 1, minorVersion: 28 },
|
|
26
|
+
{ machineName: "H5P.Video", majorVersion: 1, minorVersion: 6 },
|
|
27
|
+
],
|
|
28
|
+
embedTypes: ["iframe", "div"],
|
|
29
|
+
contentType: "Interactive Video",
|
|
30
|
+
description: "An interactive video content type",
|
|
31
|
+
author: "H5P Community",
|
|
32
|
+
authors: [{ name: "Jane Doe", role: "Author" }],
|
|
33
|
+
license: "CC BY",
|
|
34
|
+
licenseVersion: "4.0",
|
|
35
|
+
licenseExtras: "Attribution required",
|
|
36
|
+
yearFrom: 2020,
|
|
37
|
+
yearTo: 2024,
|
|
38
|
+
defaultLanguage: "en",
|
|
39
|
+
a11yTitle: "Interactive Video",
|
|
40
|
+
changes: [{ date: "01-01-24 00:00:00", changes: ["Initial release"] }],
|
|
41
|
+
});
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("H5P PackageManifest rejects missing required title", () => {
|
|
46
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
47
|
+
language: "en",
|
|
48
|
+
machineName: "H5P.Image",
|
|
49
|
+
mainLibrary: "H5P.Image",
|
|
50
|
+
preloadedDependencies: [{ machineName: "H5P.Image", majorVersion: 1, minorVersion: 1 }],
|
|
51
|
+
embedTypes: ["div"],
|
|
52
|
+
});
|
|
53
|
+
expect(result.success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("H5P PackageManifest rejects invalid machine name", () => {
|
|
57
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
58
|
+
title: "Test",
|
|
59
|
+
language: "en",
|
|
60
|
+
machineName: "invalid machine name!",
|
|
61
|
+
mainLibrary: "H5P.Image",
|
|
62
|
+
preloadedDependencies: [{ machineName: "H5P.Image", majorVersion: 1, minorVersion: 1 }],
|
|
63
|
+
embedTypes: ["div"],
|
|
64
|
+
});
|
|
65
|
+
expect(result.success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("H5P PackageManifest rejects invalid embed type", () => {
|
|
69
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
70
|
+
title: "Test",
|
|
71
|
+
language: "en",
|
|
72
|
+
machineName: "H5P.Image",
|
|
73
|
+
mainLibrary: "H5P.Image",
|
|
74
|
+
preloadedDependencies: [{ machineName: "H5P.Image", majorVersion: 1, minorVersion: 1 }],
|
|
75
|
+
embedTypes: ["flash"],
|
|
76
|
+
});
|
|
77
|
+
expect(result.success).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("H5P PackageManifest rejects yearTo < yearFrom", () => {
|
|
81
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
82
|
+
title: "Test",
|
|
83
|
+
language: "en",
|
|
84
|
+
machineName: "H5P.Image",
|
|
85
|
+
mainLibrary: "H5P.Image",
|
|
86
|
+
preloadedDependencies: [{ machineName: "H5P.Image", majorVersion: 1, minorVersion: 1 }],
|
|
87
|
+
embedTypes: ["div"],
|
|
88
|
+
yearFrom: 2024,
|
|
89
|
+
yearTo: 2020,
|
|
90
|
+
});
|
|
91
|
+
expect(result.success).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("H5P PackageManifest rejects duplicate embed types", () => {
|
|
95
|
+
const result = H5pV1.Schemas.PackageManifest.safeParse({
|
|
96
|
+
title: "Test",
|
|
97
|
+
language: "en",
|
|
98
|
+
machineName: "H5P.Image",
|
|
99
|
+
mainLibrary: "H5P.Image",
|
|
100
|
+
preloadedDependencies: [{ machineName: "H5P.Image", majorVersion: 1, minorVersion: 1 }],
|
|
101
|
+
embedTypes: ["iframe", "iframe"],
|
|
102
|
+
});
|
|
103
|
+
expect(result.success).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- LibraryManifest (library.json) ---
|
|
107
|
+
|
|
108
|
+
test("H5P LibraryManifest accepts a minimal valid library.json", () => {
|
|
109
|
+
const result = H5pV1.Schemas.LibraryManifest.safeParse({
|
|
110
|
+
title: "Image",
|
|
111
|
+
machineName: "H5P.Image",
|
|
112
|
+
majorVersion: 1,
|
|
113
|
+
minorVersion: 1,
|
|
114
|
+
patchVersion: 33,
|
|
115
|
+
runnable: 0,
|
|
116
|
+
});
|
|
117
|
+
expect(result.success).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("H5P LibraryManifest accepts a runnable library with full fields", () => {
|
|
121
|
+
const result = H5pV1.Schemas.LibraryManifest.safeParse({
|
|
122
|
+
title: "True/False Question",
|
|
123
|
+
machineName: "H5P.TrueFalse",
|
|
124
|
+
majorVersion: 1,
|
|
125
|
+
minorVersion: 8,
|
|
126
|
+
patchVersion: 24,
|
|
127
|
+
runnable: 1,
|
|
128
|
+
description: "True/False question content type",
|
|
129
|
+
author: "H5P Community",
|
|
130
|
+
license: "MIT",
|
|
131
|
+
preloadedJs: [{ path: "h5p-true-false.js" }],
|
|
132
|
+
preloadedCss: [{ path: "h5p-true-false.css" }],
|
|
133
|
+
preloadedDependencies: [
|
|
134
|
+
{ machineName: "H5P.Question", majorVersion: 1, minorVersion: 5 },
|
|
135
|
+
{ machineName: "H5P.JoubelUI", majorVersion: 1, minorVersion: 3 },
|
|
136
|
+
],
|
|
137
|
+
embedTypes: ["iframe"],
|
|
138
|
+
w: 640,
|
|
139
|
+
h: 480,
|
|
140
|
+
fullscreen: 0,
|
|
141
|
+
contentType: "Question",
|
|
142
|
+
coreApi: { majorVersion: 1, minorVersion: 28 },
|
|
143
|
+
metadataSettings: { disable: 0, disableExtraTitleField: 0 },
|
|
144
|
+
});
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("H5P LibraryManifest rejects invalid machineName", () => {
|
|
149
|
+
const result = H5pV1.Schemas.LibraryManifest.safeParse({
|
|
150
|
+
title: "Bad Library",
|
|
151
|
+
machineName: "bad name with spaces",
|
|
152
|
+
majorVersion: 1,
|
|
153
|
+
minorVersion: 0,
|
|
154
|
+
patchVersion: 0,
|
|
155
|
+
runnable: 0,
|
|
156
|
+
});
|
|
157
|
+
expect(result.success).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// --- Shared schemas ---
|
|
161
|
+
|
|
162
|
+
test("H5P VersionRef rejects patch version (not part of the schema)", () => {
|
|
163
|
+
// The schema only allows machineName, majorVersion, minorVersion
|
|
164
|
+
const result = H5pV1.Shared.VersionRef.safeParse({
|
|
165
|
+
machineName: "H5P.Image",
|
|
166
|
+
majorVersion: 1,
|
|
167
|
+
minorVersion: 1,
|
|
168
|
+
patchVersion: 33,
|
|
169
|
+
});
|
|
170
|
+
// strictObject rejects extra fields
|
|
171
|
+
expect(result.success).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("H5P LibraryFolderName validates correct folder name", () => {
|
|
175
|
+
expect(H5pV1.Shared.LibraryFolderName.safeParse("H5P.Image-1.1").success).toBe(true);
|
|
176
|
+
expect(H5pV1.Shared.LibraryFolderName.safeParse("H5P.Column-1.22").success).toBe(true);
|
|
177
|
+
expect(H5pV1.Shared.LibraryFolderName.safeParse("H5P.InteractiveVideo-1.28").success).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("H5P LibraryFolderName rejects folder names with patch version", () => {
|
|
181
|
+
expect(H5pV1.Shared.LibraryFolderName.safeParse("H5P.Image-1.1.33").success).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- Semantics (semantics.json) ---
|
|
185
|
+
|
|
186
|
+
test("H5P Semantics accepts a simple text field", () => {
|
|
187
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
188
|
+
name: "question",
|
|
189
|
+
type: "text",
|
|
190
|
+
label: "Question",
|
|
191
|
+
importance: "high",
|
|
192
|
+
maxLength: 500,
|
|
193
|
+
});
|
|
194
|
+
expect(result.success).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("H5P Semantics accepts a boolean field", () => {
|
|
198
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
199
|
+
name: "enableRetry",
|
|
200
|
+
type: "boolean",
|
|
201
|
+
label: "Enable Retry",
|
|
202
|
+
default: true,
|
|
203
|
+
optional: true,
|
|
204
|
+
});
|
|
205
|
+
expect(result.success).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("H5P Semantics accepts a select field with options", () => {
|
|
209
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
210
|
+
name: "correct",
|
|
211
|
+
type: "select",
|
|
212
|
+
label: "Correct Answer",
|
|
213
|
+
options: [
|
|
214
|
+
{ value: "true", label: "True" },
|
|
215
|
+
{ value: "false", label: "False" },
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
expect(result.success).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("H5P Semantics accepts a group with nested fields", () => {
|
|
222
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
223
|
+
name: "behaviour",
|
|
224
|
+
type: "group",
|
|
225
|
+
label: "Behavioural settings",
|
|
226
|
+
expanded: true,
|
|
227
|
+
fields: [
|
|
228
|
+
{ name: "enableRetry", type: "boolean", label: "Enable retry button" },
|
|
229
|
+
{ name: "enableSolutionsButton", type: "boolean", label: "Enable solution button" },
|
|
230
|
+
{ name: "autoCheck", type: "boolean", label: "Auto-check" },
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
expect(result.success).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("H5P Semantics accepts a list field containing a group", () => {
|
|
237
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
238
|
+
name: "alternatives",
|
|
239
|
+
type: "list",
|
|
240
|
+
label: "Answer Alternatives",
|
|
241
|
+
min: 2,
|
|
242
|
+
entity: "alternative",
|
|
243
|
+
field: {
|
|
244
|
+
name: "alternative",
|
|
245
|
+
type: "group",
|
|
246
|
+
fields: [
|
|
247
|
+
{ name: "text", type: "html", label: "Text", importance: "high" },
|
|
248
|
+
{ name: "correct", type: "boolean", label: "Correct" },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
expect(result.success).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("H5P Semantics accepts deeply nested groups (3 levels)", () => {
|
|
256
|
+
const result = H5pV1.Schemas.Semantics.safeParse([
|
|
257
|
+
{
|
|
258
|
+
name: "outer",
|
|
259
|
+
type: "group",
|
|
260
|
+
fields: [
|
|
261
|
+
{
|
|
262
|
+
name: "middle",
|
|
263
|
+
type: "group",
|
|
264
|
+
fields: [{ name: "inner", type: "text", label: "Inner text" }],
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
]);
|
|
269
|
+
expect(result.success).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("H5P Semantics accepts library field with options", () => {
|
|
273
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
274
|
+
name: "content",
|
|
275
|
+
type: "library",
|
|
276
|
+
label: "Content",
|
|
277
|
+
options: ["H5P.Image 1.1", "H5P.Video 1.6", "H5P.Audio 1.5"],
|
|
278
|
+
});
|
|
279
|
+
expect(result.success).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("H5P Semantics accepts an image field", () => {
|
|
283
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
284
|
+
name: "file",
|
|
285
|
+
type: "image",
|
|
286
|
+
label: "Image",
|
|
287
|
+
importance: "high",
|
|
288
|
+
allowedMimeTypes: ["image/jpeg", "image/png", "image/gif", "image/svg+xml"],
|
|
289
|
+
});
|
|
290
|
+
expect(result.success).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("H5P Semantics rejects unknown field type", () => {
|
|
294
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
295
|
+
name: "bad",
|
|
296
|
+
type: "richtext",
|
|
297
|
+
label: "Bad Type",
|
|
298
|
+
});
|
|
299
|
+
expect(result.success).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("H5P Semantics rejects field without name", () => {
|
|
303
|
+
const result = H5pV1.Schemas.SemanticsField.safeParse({
|
|
304
|
+
type: "text",
|
|
305
|
+
label: "No name",
|
|
306
|
+
});
|
|
307
|
+
expect(result.success).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// --- MediaFile (content.json sub-structure) ---
|
|
311
|
+
|
|
312
|
+
test("H5P MediaFile accepts a valid image file reference", () => {
|
|
313
|
+
const result = H5pV1.Schemas.MediaFile.safeParse({
|
|
314
|
+
path: "images/my-photo.jpg",
|
|
315
|
+
mime: "image/jpeg",
|
|
316
|
+
copyright: { license: "CC BY", author: "John Doe", year: "2024" },
|
|
317
|
+
width: 1920,
|
|
318
|
+
height: 1080,
|
|
319
|
+
});
|
|
320
|
+
expect(result.success).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("H5P LibraryEmbed accepts a valid embedded library reference", () => {
|
|
324
|
+
const result = H5pV1.Schemas.LibraryEmbed.safeParse({
|
|
325
|
+
library: "H5P.Image 1.1",
|
|
326
|
+
params: { file: { path: "images/img.jpg", mime: "image/jpeg" }, alt: "A photo" },
|
|
327
|
+
subContentId: "abc123",
|
|
328
|
+
});
|
|
329
|
+
expect(result.success).toBe(true);
|
|
330
|
+
});
|