@citolab/tspci 2.12.0 → 2.13.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.
@@ -0,0 +1,114 @@
1
+ // The authoring descriptor: what an authoring system needs to know to configure and score a PCI.
2
+ //
3
+ // A PCI developer writes two files tspci projects already have:
4
+ // src/config.json the data- properties: a default value, or { default, editor, label, ... }
5
+ // package.json config.tspci: typeIdentifier, label, markup slots and the response shape
6
+ // The build turns them into
7
+ // the bundle only the defaults (flattenConfig), as before
8
+ // dist/authoring.json the descriptor (buildDescriptor), shipped next to the bundle like the SBOM
9
+ // See AUTHORING.md (in every project tspci init creates) and the readme, "Authoring systems".
10
+
11
+ export const DESCRIPTOR_VERSION = "0";
12
+ export const DESCRIPTOR_FILE = "authoring.json";
13
+
14
+ /**
15
+ * Editors for a data- property. An authoring system that does not know one shows a text field.
16
+ * `pci`: the PCI fills it itself, in its authoring mode (`getAuthoringInstance`, see AUTHORING.md "Authoring mode").
17
+ */
18
+ export const PROPERTY_EDITORS = ["text", "textarea", "integer", "number", "boolean", "enum", "color", "colorList", "hidden", "pci"];
19
+
20
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
21
+
22
+ /** config.json → the plain defaults the PCI receives (every value a string, as a data- attribute gives it). */
23
+ export function flattenConfig(config) {
24
+ const flat = {};
25
+ for (const [key, entry] of Object.entries(config ?? {})) {
26
+ flat[key] = isObject(entry) ? String(entry.default ?? "") : String(entry);
27
+ }
28
+ return flat;
29
+ }
30
+
31
+ /** A plain config.json value, as tspci PCIs always had: the editor follows from the value (as tspci-tao does). */
32
+ const editorFor = (value) => (/^(true|false)$/.test(value) ? "boolean" : /^-?\d+$/.test(value) ? "integer" : "text");
33
+
34
+ function toProperty(key, entry, problems) {
35
+ if (!isObject(entry)) {
36
+ const value = String(entry);
37
+ return { editor: editorFor(value), default: value };
38
+ }
39
+ const { default: value = "", editor = "text", $comment, ...rest } = entry;
40
+ if (!PROPERTY_EDITORS.includes(editor)) {
41
+ problems.push(`config.json ${key}: unknown editor "${editor}" (one of ${PROPERTY_EDITORS.join(", ")})`);
42
+ }
43
+ if (editor === "enum") {
44
+ const options = Array.isArray(rest.options) ? rest.options : [];
45
+ if (!options.length) problems.push(`config.json ${key}: an enum needs options`);
46
+ rest.options = options.map((o) => (isObject(o) ? o : { value: String(o) }));
47
+ if (value !== "" && !rest.options.some((o) => String(o.value) === String(value))) {
48
+ problems.push(`config.json ${key}: the default "${value}" is not one of the options`);
49
+ }
50
+ }
51
+ return { editor, default: String(value), ...rest };
52
+ }
53
+
54
+ function checkResponse(response, problems) {
55
+ if (!response) return;
56
+ const cardinality = response.cardinality ?? "single";
57
+ if (!["single", "multiple", "ordered", "record"].includes(cardinality)) {
58
+ problems.push(`config.tspci.response.cardinality: "${cardinality}" is not a QTI cardinality`);
59
+ }
60
+ if (cardinality === "record") {
61
+ const fields = Object.entries(response.fields ?? {});
62
+ if (!fields.length) problems.push("config.tspci.response: a record needs fields");
63
+ for (const [name, field] of fields) {
64
+ if (!field?.baseType) problems.push(`config.tspci.response.fields.${name}: baseType is missing`);
65
+ }
66
+ } else if (!response.baseType) {
67
+ problems.push("config.tspci.response: baseType is missing");
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The authoring mode of a PCI: present when a property has editor `pci`. config.tspci.authoring adds a label for the
73
+ * button that opens it and the size the PCI would like for it.
74
+ */
75
+ function toAuthoring(authoring, properties, problems) {
76
+ const managed = Object.keys(properties).filter((key) => properties[key].editor === "pci");
77
+ if (!managed.length) {
78
+ if (authoring) problems.push("config.tspci.authoring: no property in config.json has editor \"pci\", so there is nothing to author");
79
+ return null;
80
+ }
81
+ return { properties: managed, ...(isObject(authoring) ? authoring : {}) };
82
+ }
83
+
84
+ /**
85
+ * The descriptor of a PCI project. `problems` lists what an authoring system could not use (an unknown editor, an
86
+ * enum default that is not an option, a record field without a base type); the build reports them as warnings.
87
+ */
88
+ export function buildDescriptor(packageJson, config) {
89
+ const tspci = packageJson?.config?.tspci ?? {};
90
+ const problems = [];
91
+ if (!tspci.typeIdentifier) problems.push("package.json config.tspci.typeIdentifier is missing");
92
+
93
+ const properties = {};
94
+ for (const [key, entry] of Object.entries(config ?? {})) properties[key] = toProperty(key, entry, problems);
95
+
96
+ const response = tspci.response ?? { cardinality: "single", baseType: "string" };
97
+ checkResponse(response, problems);
98
+ const authoring = toAuthoring(tspci.authoring, properties, problems);
99
+
100
+ return {
101
+ descriptor: {
102
+ descriptorVersion: DESCRIPTOR_VERSION,
103
+ typeIdentifier: tspci.typeIdentifier ?? "",
104
+ version: packageJson?.version ?? "",
105
+ label: tspci.label ?? packageJson?.name ?? "",
106
+ properties,
107
+ markup: tspci.markup ?? {},
108
+ response,
109
+ ...(tspci.score ? { score: tspci.score } : {}),
110
+ ...(authoring ? { authoring } : {}),
111
+ },
112
+ problems,
113
+ };
114
+ }
@@ -0,0 +1,32 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { buildDescriptor, DESCRIPTOR_FILE, flattenConfig } from "./authoring.js";
4
+
5
+ /**
6
+ * - `import config from "./config.json"` gets the plain defaults, also when config.json carries editor hints
7
+ * (`{ "default": "640", "editor": "integer", ... }`), so the hints never end up in the bundle;
8
+ * - writes dist/authoring.json, the descriptor an authoring system reads (see authoring.js).
9
+ * Runs before @rollup/plugin-json, which then turns the flattened JSON into a module.
10
+ */
11
+ export function authoring({ projectRoot = process.cwd(), configFile = "src/config.json" } = {}) {
12
+ const configPath = path.resolve(projectRoot, configFile);
13
+ return {
14
+ name: "tspci-authoring",
15
+
16
+ transform(code, id) {
17
+ if (path.resolve(id.split("?")[0]) !== configPath) return null;
18
+ return { code: JSON.stringify(flattenConfig(JSON.parse(code)), null, 2), map: null };
19
+ },
20
+
21
+ writeBundle(options) {
22
+ const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
23
+ const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {};
24
+ const { descriptor, problems } = buildDescriptor(packageJson, config);
25
+ for (const problem of problems) this.warn(problem);
26
+
27
+ const outputDir = options.dir ?? path.dirname(options.file ?? path.join(projectRoot, "dist", "index.js"));
28
+ fs.mkdirSync(outputDir, { recursive: true });
29
+ fs.writeFileSync(path.join(outputDir, DESCRIPTOR_FILE), `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
30
+ },
31
+ };
32
+ }
@@ -1,6 +1,41 @@
1
1
  import postcss from "postcss";
2
2
  import loadPostCssConfig from "postcss-load-config";
3
3
 
4
+ /**
5
+ * A PCI renders in a shadow root, and browsers ignore `@property` there, so Tailwind 4's registered `--tw-*` variables
6
+ * lose both their initial value and `inherits: false`:
7
+ * - without the initial value, `border` has no style (`--tw-border-style: solid`), `-translate-x-1/2` no y value and
8
+ * `ring`/`shadow` nothing: borders, shadows and rings silently disappear;
9
+ * - without `inherits: false`, a variable set on a parent leaks into its children (`ring-inset` on a wrapper makes the
10
+ * rings inside it inset too).
11
+ * This sets every `inherits: false` property on every element in the earliest layer (`properties`), which every
12
+ * utility still overrides: to its initial value, or to `initial` (the guaranteed-invalid value, as if unset) when it has
13
+ * none. That resets it per element instead of inheriting it. `inherits: true` properties inherit anyway and are left out.
14
+ * CSS without `@property` is left as it is.
15
+ */
16
+ export function shadowRootPropertyDefaults(root) {
17
+ const defaults = [];
18
+ root.walkAtRules("property", (rule) => {
19
+ const name = rule.params.trim();
20
+ let initial;
21
+ let inherits;
22
+ rule.walkDecls((decl) => {
23
+ if (decl.prop === "initial-value") initial = decl.value;
24
+ if (decl.prop === "inherits") inherits = decl.value.trim();
25
+ });
26
+ if (name.startsWith("--") && inherits === "false") {
27
+ defaults.push(postcss.decl({ prop: name, value: initial ?? "initial" }));
28
+ }
29
+ });
30
+ if (defaults.length === 0) {
31
+ return root;
32
+ }
33
+ const rule = postcss.rule({ selectors: [":host", "*", "::before", "::after", "::backdrop"] });
34
+ rule.append(defaults);
35
+ root.append(postcss.atRule({ name: "layer", params: "properties", nodes: [rule] }));
36
+ return root;
37
+ }
38
+
4
39
  export function postcssString(options = {}) {
5
40
  const extensions = options.extensions || [".css"];
6
41
  const configEnabled = options.config !== false;
@@ -47,8 +82,9 @@ export function postcssString(options = {}) {
47
82
  map: false,
48
83
  });
49
84
 
85
+ const css = shadowRootPropertyDefaults(result.root).toString();
50
86
  return {
51
- code: `export default ${JSON.stringify(result.css)};`,
87
+ code: `export default ${JSON.stringify(css)};`,
52
88
  map: { mappings: "" },
53
89
  };
54
90
  },
@@ -2,16 +2,33 @@ import livereload from "rollup-plugin-livereload";
2
2
  // PK: not the official npm package, but a copy, needed watch functionality
3
3
  import { liveServer } from "./rollup-plugin-live-server.js";
4
4
  import { copyFiles } from "./rollup-plugin-copy-files.js";
5
+ import fs from "fs";
5
6
  import path from "path";
7
+ import { createRequire } from "module";
6
8
  import rollupConfig from "./rollup.config.js"; // Make sure this exports an object with `output` and `plugins`
7
9
 
8
10
  const PACKAGE_ROOT_PATH = process.cwd();
9
11
  const PACKAGE_DIST_PATH = path.join(PACKAGE_ROOT_PATH, "dist");
10
12
 
13
+ // The dev page (public/index.html) loads its libraries from /vendor/<package>, served from tspci's own dependencies, so
14
+ // it needs no public CDN and runs the versions in the lockfile: RequireJS, Bootstrap, preact + htm, and qti-components
15
+ // (the PCI host, and the RequireJS / bundle / stylesheet it gives the PCI iframe).
16
+ const require = createRequire(import.meta.url);
17
+ // The folder of an installed package, found the way Node looks for it (the node_modules folders up from here), so a
18
+ // package's "exports" (import-only, or without package.json) does not matter.
19
+ const packageDir = (name) => {
20
+ const dir = (require.resolve.paths(name) ?? [])
21
+ .map((modules) => path.join(modules, name))
22
+ .find((candidate) => fs.existsSync(path.join(candidate, "package.json")));
23
+ if (!dir) throw new Error(`tspci dev page: ${name} is not installed`);
24
+ return dir;
25
+ };
26
+ const VENDOR_PACKAGES = ["requirejs", "bootstrap", "preact", "htm", "@citolab/qti-components"];
27
+
11
28
  // Configure live server
12
29
  const livesrvr = liveServer({
13
30
  root: path.join(PACKAGE_ROOT_PATH),
14
- mount: [["/", "./dist"]],
31
+ mount: [["/", "./dist"], ...VENDOR_PACKAGES.map((name) => [`/vendor/${name}`, packageDir(name)])],
15
32
  open: true,
16
33
  wait: 500,
17
34
  watch: PACKAGE_DIST_PATH,
@@ -7,6 +7,7 @@ import commonjs from "@rollup/plugin-commonjs"; // added this when added react-r
7
7
  import json from "@rollup/plugin-json";
8
8
  import { postcssString } from "./rollup-plugin-postcss-string.js";
9
9
  import { copyFiles } from "./rollup-plugin-copy-files.js";
10
+ import { authoring } from "./rollup-plugin-authoring.js";
10
11
 
11
12
  import fs from "fs";
12
13
  import path from "path";
@@ -52,6 +53,7 @@ export default {
52
53
  browser: true
53
54
  }),
54
55
  commonjs({ transformMixedEsModules: true }), // preact was imported twice, with require (unistore/preact) and with import (our import), which resulted in 2 preacts in our project
56
+ authoring({ projectRoot: PACKAGE_ROOT_PATH }), // config.json → plain defaults in the bundle, dist/authoring.json for authoring systems
55
57
  json(), // used to import config.json
56
58
  typescript({
57
59
  tsconfig: `${PACKAGE_ROOT_PATH}/tsconfig.json`,
Binary file
package/types/index.d.ts CHANGED
@@ -32,6 +32,132 @@ export interface ResponseDeclaration {
32
32
  correctResponse?: CorrectResponse;
33
33
  }
34
34
 
35
+ /**
36
+ * The properties a PCI receives, for a config.json whose values may carry editor hints
37
+ * (`{ "default": "640", "editor": "integer" }`): the build passes only the defaults, and like every data-
38
+ * attribute they arrive as strings. Use `type PropTypes = PciProperties<typeof configProps>`.
39
+ */
40
+ export type PciProperties<T> = { [K in keyof T]: string };
41
+
42
+ /** A translatable text in the authoring descriptor: one string, or one per language (`{ "en": …, "nl": … }`). */
43
+ export type AuthoringText = string | Record<string, string>;
44
+
45
+ /**
46
+ * How an authoring system edits a data- property (config.json). Unknown editors fall back to a text field.
47
+ * `pci`: the PCI fills it itself in its authoring mode (see IMSpciAuthoringFactory).
48
+ */
49
+ export type AuthoringPropertyEditor =
50
+ | "text" | "textarea" | "integer" | "number" | "boolean" | "enum" | "color" | "colorList" | "hidden" | "pci";
51
+
52
+ export interface AuthoringProperty {
53
+ editor: AuthoringPropertyEditor;
54
+ /** The default, as the data- attribute holds it. */
55
+ default: string;
56
+ label?: AuthoringText;
57
+ /** enum: the values to choose from. */
58
+ options?: { value: string; label?: AuthoringText }[];
59
+ /** integer / number */
60
+ min?: number;
61
+ max?: number;
62
+ [hint: string]: unknown;
63
+ }
64
+
65
+ /** A field of a record response (several values in one response variable). */
66
+ export interface AuthoringResponseField {
67
+ baseType: string;
68
+ /** How the authoring system edits the correct value (e.g. "mediaTime", "mediaPoint"); a text field by default. */
69
+ editor?: string;
70
+ label?: AuthoringText;
71
+ /** The kind of margin a score rule may give it: "range" (numbers), "area" (points). */
72
+ tolerance?: string;
73
+ [hint: string]: unknown;
74
+ }
75
+
76
+ /**
77
+ * dist/authoring.json, generated from src/config.json and package.json config.tspci, and shipped next to the bundle.
78
+ * See AUTHORING.md.
79
+ */
80
+ export interface AuthoringDescriptor {
81
+ descriptorVersion: string;
82
+ typeIdentifier: string;
83
+ version: string;
84
+ label: string;
85
+ properties: Record<string, AuthoringProperty>;
86
+ /** Elements the item puts in qti-interaction-markup, e.g. `{ "video": { "editor": "media", "accept": ["video/*"] } }`. */
87
+ markup: Record<string, { editor: string; element?: string; accept?: string[]; required?: boolean; label?: AuthoringText }>;
88
+ response:
89
+ | { cardinality: "single" | "multiple" | "ordered"; baseType: string }
90
+ | { cardinality: "record"; fields: Record<string, AuthoringResponseField> };
91
+ /** The response processing templates the PCI is meant for (TAO's score setting), e.g. ["MATCH_CORRECT", "NONE"]. */
92
+ score?: string[];
93
+ /** Present when the PCI has an authoring mode: properties with editor "pci", plus config.tspci.authoring. */
94
+ authoring?: AuthoringMode;
95
+ }
96
+
97
+ /** The authoring mode in the descriptor. The authoring system shows one button that opens it. */
98
+ export interface AuthoringMode {
99
+ /** The properties the PCI fills itself (editor "pci"), in config.json order. */
100
+ properties: string[];
101
+ /** The text of the button, e.g. `{ "en": "Draw the shape…", "nl": "Vorm tekenen…" }`. */
102
+ label?: AuthoringText;
103
+ /** The size (px) the authoring UI would like; the authoring system may give less. */
104
+ width?: number;
105
+ height?: number;
106
+ [hint: string]: unknown;
107
+ }
108
+
109
+ /**
110
+ * What the authoring system passes to getAuthoringInstance. `properties` are the item's data- properties, all of
111
+ * them (also the ones the PCI does not fill itself, so it can show the shape in the item's colours), as strings.
112
+ */
113
+ export interface PciAuthoringConfiguration<T> {
114
+ properties: Partial<T>;
115
+ /** The UI language of the authoring system ("nl", "en"). */
116
+ language?: string;
117
+ /** Call when the authoring UI is ready to be used. */
118
+ onready?: (instance: PciAuthoringInstance) => void;
119
+ /** Call after every change the author makes (the host may enable "Done" or warn about unsaved work). */
120
+ onchange?: () => void;
121
+ }
122
+
123
+ /** A PCI in authoring mode: the author shapes its configuration, the host asks for it when the author is done. */
124
+ export interface PciAuthoringInstance {
125
+ /** The managed properties (descriptor authoring.properties) as data- attribute strings; others are ignored. */
126
+ getConfig: () => Record<string, string>;
127
+ /** What keeps the configuration from being used (shown to the author, "Done" waits for an empty list). */
128
+ validate?: () => string[];
129
+ destroy?: () => void;
130
+ }
131
+
132
+
133
+ /**
134
+ * postMessage protocol between an authoring system (host) and the sandboxed iframe it runs a PCI's authoring mode in.
135
+ * The frame is the host's own bootstrap page; the PCI itself never posts messages.
136
+ *
137
+ * Every message carries `version`: the protocol version of its sender, 1 for this protocol. It goes up only for a change
138
+ * the other side cannot handle (a new optional field keeps it). The frame checks it on init, the host on hello; a
139
+ * mismatch is reported as an error.
140
+ */
141
+ export type PciAuthoringProtocolVersion = 1;
142
+
143
+ type Versioned<M> = M & { version: PciAuthoringProtocolVersion };
144
+
145
+ export type PciAuthoringHostMessage = Versioned<
146
+ /** After the frame said hello: what to load and with which properties. */
147
+ | { type: "pci-authoring:init"; moduleUrl: string; typeIdentifier: string; properties: Record<string, string>; language?: string }
148
+ /** The author pressed "Done": reply with pci-authoring:config. */
149
+ | { type: "pci-authoring:request-config" }
150
+ >;
151
+
152
+ export type PciAuthoringFrameMessage = Versioned<
153
+ | { type: "pci-authoring:hello" }
154
+ | { type: "pci-authoring:ready" }
155
+ | { type: "pci-authoring:change" }
156
+ | { type: "pci-authoring:config"; config: Record<string, string>; problems: string[] }
157
+ /** The module did not load, has no getAuthoringInstance, or the host speaks another protocol version. */
158
+ | { type: "pci-authoring:error"; message: string }
159
+ >;
160
+
35
161
  // Define the ConfigProperties interface
36
162
  export interface ConfigProperties<T> {
37
163
  properties: T; // Follows dataset conversion rules (camelCased keys)
@@ -81,6 +207,16 @@ export interface IMSpciFactory<T> {
81
207
  getInstance: (dom: HTMLElement, configuration: ConfigProperties<T>, state?: string) => IMSpci<T>;
82
208
  }
83
209
 
210
+ /**
211
+ * The QTI 3 PCI (the object registered with qtiCustomInteractionContext) extended with an authoring mode:
212
+ * getAuthoringInstance next to getInstance. Delivery engines only know getInstance and never call it, so the PCI works
213
+ * in every QTI 3 player as before; an authoring system that supports it checks
214
+ * `typeof pci.getAuthoringInstance === "function"` and calls it instead of getInstance. See AUTHORING.md, "Authoring mode".
215
+ */
216
+ export interface IMSpciAuthoringFactory<T> extends IMSpciFactory<T> {
217
+ getAuthoringInstance: (dom: HTMLElement, configuration: PciAuthoringConfiguration<T>) => PciAuthoringInstance;
218
+ }
219
+
84
220
  export interface directedPair {
85
221
  destination: string;
86
222
  source: string;