@citolab/tspci 2.12.1 → 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.
package/public/index.html CHANGED
@@ -1,19 +1,25 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <meta charset="utf-8" />
4
5
  <title>Hello RequireJS</title>
5
- <script
6
- src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
7
- integrity="sha512-c3Nl8+7g4LMSTdrm621y7kf9v3SDPnhxLNhcjFJbKECVnmZHTdo+IRO05sNLTH/D3vA6u1X32ehoLC7WFVdheg=="
8
- crossorigin="anonymous"
9
- referrerpolicy="no-referrer"
10
- ></script>
6
+ <!--
7
+ Everything this page loads comes from /vendor/<package>: tspci's own dependencies, served by the dev server
8
+ (src/rollup.config.dev.js). No public CDN, and the versions in the lockfile.
9
+ -->
10
+ <script src="/vendor/requirejs/require.js"></script>
11
+ <script type="importmap">
12
+ {
13
+ "imports": {
14
+ "preact": "/vendor/preact/dist/preact.module.js",
15
+ "preact/hooks": "/vendor/preact/hooks/dist/hooks.module.js",
16
+ "htm": "/vendor/htm/dist/htm.module.js"
17
+ }
18
+ }
19
+ </script>
11
20
  <!-- <link rel="stylesheet" class="bootswatcher" href="//bootswatch.com/5/lumen/bootstrap.min.css" /> -->
12
21
 
13
- <link
14
- rel="stylesheet"
15
- href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
16
- />
22
+ <link rel="stylesheet" href="/vendor/bootstrap/dist/css/bootstrap.min.css" />
17
23
 
18
24
  <style>
19
25
  html {
@@ -203,22 +209,16 @@
203
209
  import {
204
210
  h,
205
211
  render,
206
- } from "https://unpkg.com/preact@latest?module";
212
+ } from "preact";
207
213
  import {
208
214
  useState,
209
215
  useEffect,
210
216
  useRef,
211
- } from "https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module";
212
- import htm from "https://unpkg.com/htm?module";
217
+ } from "preact/hooks";
218
+ import htm from "htm";
213
219
  // Initialize htm with Preact
214
220
  const html = htm.bind(h);
215
221
 
216
- const CONFIG_TO_DATA_PROPERTY_KEYS = [
217
- "title",
218
- "prompt",
219
- "width",
220
- "height",
221
- ];
222
222
  const CONFIG_CHANGE_DEBOUNCE_MS = 1000;
223
223
  const INTERACTION_REFRESH_DELAY_MS = 120;
224
224
 
@@ -413,33 +413,167 @@
413
413
  applyConfigChangesDebounced();
414
414
  }
415
415
 
416
+ // dist/authoring.json (written by the build, see AUTHORING.md): the same typed form an authoring system shows the
417
+ // item author. Without it every property is a text field, as before.
418
+ let authoringDescriptor = null;
419
+ const authoringLoaded = fetch("authoring.json")
420
+ .then((r) => (r.ok ? r.json() : null))
421
+ .then((d) => {
422
+ authoringDescriptor = d && d.properties ? d : null;
423
+ renderConfigPanel();
424
+ })
425
+ .catch(() => {});
426
+
427
+ const authoringText = (text, fallback) =>
428
+ !text ? fallback : typeof text === "string" ? text : text.en || Object.values(text)[0] || fallback;
429
+ const toHex = (c) => {
430
+ const v = String(c || "").trim();
431
+ if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
432
+ if (/^#[0-9a-f]{3}$/i.test(v)) return "#" + v.slice(1).split("").map((x) => x + x).join("").toLowerCase();
433
+ return "#000000";
434
+ };
435
+
436
+ function PropertyControl({ name, spec, value }) {
437
+ const set = (v) => changeConfigProperty(name, v);
438
+ switch (spec?.editor) {
439
+ case "enum":
440
+ return html`<select class="form-select" onChange="${(e) => set(e.target.value)}">
441
+ ${(spec.options || []).map(
442
+ (o) => html`<option value="${o.value}" selected=${String(o.value) === String(value)}>${authoringText(o.label, o.value)}</option>`,
443
+ )}
444
+ </select>`;
445
+ case "boolean":
446
+ return html`<div class="form-check form-switch">
447
+ <input class="form-check-input" type="checkbox" checked=${value === "true"} onChange="${(e) => set(e.target.checked ? "true" : "false")}" />
448
+ </div>`;
449
+ case "integer":
450
+ case "number":
451
+ return html`<input type="number" class="form-control" value="${value}" min="${spec.min ?? ""}" max="${spec.max ?? ""}"
452
+ step="${spec.editor === "integer" ? 1 : "any"}" onInput="${(e) => set(e.target.value)}" />`;
453
+ case "pci": // the PCI fills it in its authoring mode (button above)
454
+ return html`<code class="d-block small text-truncate" title="${value}">${value || "(default)"}</code>`;
455
+ case "textarea":
456
+ return html`<textarea class="form-control" rows="2" onInput="${(e) => set(e.target.value)}">${value}</textarea>`;
457
+ case "color":
458
+ return html`<div class="d-flex gap-2">
459
+ <input type="color" class="form-control form-control-color" value="${toHex(value)}" onInput="${(e) => set(e.target.value)}" />
460
+ <input type="text" class="form-control" value="${value}" onInput="${(e) => set(e.target.value)}" />
461
+ </div>`;
462
+ case "colorList": {
463
+ const colors = String(value || "").split(/[,;\s]+/).filter(Boolean);
464
+ const write = (next) => set(next.join(", "));
465
+ return html`<div class="d-flex flex-wrap gap-1 align-items-center">
466
+ ${colors.map(
467
+ (c, i) => html`<input type="color" class="form-control form-control-color" title="${c}" value="${toHex(c)}"
468
+ onInput="${(e) => write(colors.map((x, j) => (j === i ? e.target.value : x)))}" />`,
469
+ )}
470
+ <button type="button" class="btn btn-sm btn-outline-secondary" title="add" onClick="${() => write([...colors, colors[colors.length - 1] || "#888888"])}">+</button>
471
+ <button type="button" class="btn btn-sm btn-outline-secondary" title="remove" disabled=${colors.length < 2} onClick="${() => write(colors.slice(0, -1))}">−</button>
472
+ </div>`;
473
+ }
474
+ default:
475
+ return html`<input type="text" class="form-control" value="${value}" onInput="${(e) => set(e.target.value)}" />`;
476
+ }
477
+ }
478
+
479
+ // Authoring mode (AUTHORING.md): the PCI's own editor for the properties with editor "pci". An authoring system runs
480
+ // it in a sandboxed iframe (src/authoring-frame.js); here it runs in place of the preview, which is enough to build it.
481
+ let authoringSession = null;
482
+
483
+ function startAuthoring() {
484
+ const container = document.getElementById("pciContainer");
485
+ if (typeof currentPciInteraction?.oncompleted === "function") currentPciInteraction.oncompleted();
486
+ container.innerHTML = "";
487
+ const dom = document.createElement("div");
488
+ container.appendChild(dom);
489
+ const instance = currentPciFactory.getAuthoringInstance(dom, {
490
+ properties: { ...(currentPciConfig?.properties || {}) },
491
+ language: (navigator.language || "en").slice(0, 2),
492
+ onchange: () => {},
493
+ });
494
+ authoringSession = { instance, problems: [] };
495
+ renderConfigPanel();
496
+ }
497
+
498
+ function finishAuthoring(apply) {
499
+ const session = authoringSession;
500
+ if (!session) return;
501
+ if (apply) {
502
+ const problems = (typeof session.instance.validate === "function" && session.instance.validate()) || [];
503
+ if (problems.length) {
504
+ session.problems = problems;
505
+ renderConfigPanel();
506
+ return;
507
+ }
508
+ const values = session.instance.getConfig() || {};
509
+ // only the properties the descriptor says the PCI manages
510
+ for (const key of authoringDescriptor.authoring.properties) {
511
+ if (key in values) currentPciConfig.properties[key] = String(values[key]);
512
+ }
513
+ localStorage.setItem(currentPciFactory.typeIdentifier + "_props", JSON.stringify(currentPciConfig.properties));
514
+ syncConfigPropertiesToHostDataProperties(currentPciConfig, currentPciHostDataProperties);
515
+ }
516
+ if (typeof session.instance.destroy === "function") session.instance.destroy();
517
+ authoringSession = null;
518
+ refreshCurrentPci();
519
+ renderConfigPanel();
520
+ }
521
+
522
+ function AuthoringControls() {
523
+ const mode = authoringDescriptor?.authoring;
524
+ if (!mode) return null;
525
+ if (typeof currentPciFactory?.getAuthoringInstance !== "function") {
526
+ return html`<p class="small text-warning mb-3">
527
+ <code>authoring.json</code> describes an authoring mode, but the PCI has no <code>getAuthoringInstance</code>.
528
+ </p>`;
529
+ }
530
+ if (!authoringSession) {
531
+ return html`<button type="button" class="btn btn-sm btn-primary mb-3" onClick="${startAuthoring}">
532
+ ${authoringText(mode.label, "Configure…")}
533
+ </button>`;
534
+ }
535
+ return html`<div class="mb-3">
536
+ <p class="small text-muted mb-2">Authoring mode: the item author's view. Done writes ${mode.properties.map((k) => html`<code>${k}</code> `)}.</p>
537
+ ${authoringSession.problems.map((problem) => html`<div class="alert alert-warning py-1 px-2 small mb-2">${problem}</div>`)}
538
+ <div class="d-flex gap-2">
539
+ <button type="button" class="btn btn-sm btn-primary" onClick="${() => finishAuthoring(true)}">Done</button>
540
+ <button type="button" class="btn btn-sm btn-outline-secondary" onClick="${() => finishAuthoring(false)}">Cancel</button>
541
+ </div>
542
+ </div>`;
543
+ }
544
+
416
545
  function ConfigPanel() {
417
546
  const configSource = currentPciConfig;
547
+ const properties = authoringDescriptor?.properties || {};
418
548
  const keyArray = configSource?.properties
419
- ? Object.keys(configSource.properties).map((keyName) => ({
420
- key: keyName,
421
- value: configSource.properties[keyName],
422
- }))
549
+ ? Object.keys(configSource.properties)
550
+ .filter((keyName) => properties[keyName]?.editor !== "hidden")
551
+ .map((keyName) => ({ key: keyName, value: configSource.properties[keyName], spec: properties[keyName] }))
423
552
  : [];
553
+ const response = authoringDescriptor?.response;
424
554
 
425
555
  return html`
426
556
  <aside class="dev-sidepane">
427
- <h6 class="mb-3">Config</h6>
557
+ <h6 class="mb-1">Config</h6>
558
+ <p class="text-muted small mb-3">
559
+ ${authoringDescriptor
560
+ ? html`As an item author sees it (<code>authoring.json</code>)${response
561
+ ? html` · response <code>${response.cardinality}${response.baseType ? " " + response.baseType : ""}</code>`
562
+ : ""}`
563
+ : html`No <code>authoring.json</code> yet: build once, or see AUTHORING.md`}
564
+ </p>
565
+ <${AuthoringControls} />
566
+ <fieldset disabled=${!!authoringSession}>
428
567
  <form onSubmit="${(e) => e.preventDefault()}">
429
568
  ${keyArray.map(
430
- ({ key, value }) =>
431
- html`<div class="mb-2">
432
- <label class="form-label text-muted mb-1">${key}</label>
433
- <input
434
- type="text"
435
- class="form-control"
436
- value="${value}"
437
- onInput="${(e) =>
438
- changeConfigProperty(key, e.target.value)}"
439
- />
569
+ ({ key, value, spec }) =>
570
+ html`<div class="mb-3">
571
+ <label class="form-label text-muted mb-1" title="${key}">${authoringText(spec?.label, key)}</label>
572
+ <${PropertyControl} name="${key}" spec="${spec}" value="${value}" />
440
573
  </div>`,
441
574
  )}
442
575
  </form>
576
+ </fieldset>
443
577
  </aside>
444
578
  `;
445
579
  }
@@ -720,25 +854,26 @@
720
854
  localStorage.removeItem(item.typeIdentifier + "_state");
721
855
  }
722
856
 
857
+ // qti-components runs the PCI in an iframe. Give it these copies, so the iframe loads nothing from a CDN either
858
+ // (qti-components 9.5 still defaults to cdnjs and unpkg there; later versions inline what the iframe needs).
859
+ const QTI_COMPONENTS = {
860
+ bundle: new URL("/vendor/@citolab/qti-components/cdn/index.js", location.href).href,
861
+ css: new URL("/vendor/@citolab/qti-components/dist/item.css", location.href).href,
862
+ requireJs: new URL("/vendor/requirejs/require.js", location.href).href,
863
+ };
864
+
723
865
  async function loadQtiComponents() {
724
866
  if (customElements.get("qti-portable-custom-interaction")) {
725
867
  return true;
726
868
  }
727
- const moduleCandidates = [
728
- "https://cdn.jsdelivr.net/npm/@citolab/qti-components/+esm",
729
- "https://cdn.jsdelivr.net/npm/@citolab/qti-components/dist/index.js",
730
- "https://unpkg.com/@citolab/qti-components/dist/index.js?module",
731
- ];
732
-
733
- for (const modulePath of moduleCandidates) {
734
- try {
735
- await import(modulePath);
736
- if (customElements.get("qti-portable-custom-interaction")) {
737
- return true;
738
- }
739
- } catch (error) {
740
- // Try the next candidate.
869
+ try {
870
+ // The self-contained bundle of tspci's own qti-components dependency (see the head).
871
+ await import(QTI_COMPONENTS.bundle);
872
+ if (customElements.get("qti-portable-custom-interaction")) {
873
+ return true;
741
874
  }
875
+ } catch (error) {
876
+ console.error(error);
742
877
  }
743
878
 
744
879
  console.warn(
@@ -747,6 +882,14 @@
747
882
  return false;
748
883
  }
749
884
 
885
+ // Declare the response as config.tspci.response describes it (authoring.json), so the preview reports an integer
886
+ // as an integer; a record, or no authoring.json yet, falls back to single/string.
887
+ function responseDeclarationXml() {
888
+ const response = authoringDescriptor?.response;
889
+ const cardinality = response && response.cardinality !== "record" ? response.cardinality : "single";
890
+ const baseType = response && response.cardinality !== "record" && response.baseType ? response.baseType : "string";
891
+ return `<qti-response-declaration identifier="RESPONSE" cardinality="${escapeHtmlAttribute(cardinality)}" base-type="${escapeHtmlAttribute(baseType)}"></qti-response-declaration>`;
892
+ }
750
893
  function createQtiPciHost(item, dataProperties = {}) {
751
894
  const container = document.getElementById("pciContainer");
752
895
  container.innerHTML = "";
@@ -777,10 +920,12 @@
777
920
  const qtiItem = document.createElement("qti-item");
778
921
  qtiItem.innerHTML = `
779
922
  <qti-assessment-item identifier="PREVIEW-ITEM" title="${escapedTypeIdentifier}" adaptive="false" time-dependent="false">
780
- <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string"></qti-response-declaration>
923
+ ${responseDeclarationXml()}
781
924
  <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float"></qti-outcome-declaration>
782
925
  <qti-item-body>
783
- <qti-portable-custom-interaction response-identifier="RESPONSE" module="pci" custom-interaction-type-identifier="${escapedTypeIdentifier}"${dataAttributes}>
926
+ <qti-portable-custom-interaction response-identifier="RESPONSE" module="pci" custom-interaction-type-identifier="${escapedTypeIdentifier}"
927
+ data-require-js-url="${escapeHtmlAttribute(QTI_COMPONENTS.requireJs)}" data-components-cdn-url="${escapeHtmlAttribute(QTI_COMPONENTS.bundle)}"
928
+ data-components-css-url="${escapeHtmlAttribute(QTI_COMPONENTS.css)}"${dataAttributes}>
784
929
  <qti-interaction-modules>
785
930
  <qti-interaction-module id="pci" primary-path="./index"></qti-interaction-module>
786
931
  </qti-interaction-modules>
@@ -819,7 +964,7 @@
819
964
  if (typeof globalThis.off !== "function") {
820
965
  globalThis.off = () => {};
821
966
  }
822
- await loadQtiComponents();
967
+ await Promise.all([loadQtiComponents(), authoringLoaded]);
823
968
  loadItem();
824
969
  };
825
970
  </script>
package/readme.md CHANGED
@@ -21,6 +21,7 @@
21
21
  - First class (p)react and tailwind support
22
22
  - Extension for TAO, configuring icon, label, support for setting correct response and pci-properties
23
23
  - Generates a CycloneDX SBOM of the components that are actually in your bundle
24
+ - Tells authoring systems how to configure and score your PCI (`authoring.json`, see [Authoring systems](#authoring-systems))
24
25
 
25
26
  ## 🔧 Installation & Setup
26
27
 
@@ -41,9 +42,27 @@ Run: `npx @citolab/tspci@latest init`
41
42
  4. You should provide a description.
42
43
  5. Last option is to choose how you want to build your pci, using tailwind and preact, typescript or javascript
43
44
 
45
+ Every choice gives the same working demo, each in its own idiom: "what percentage of the circle is coloured?". The
46
+ circle is an SVG drawn from the settings in `src/config.json` (`title`, `question`, `segments`, `colored`, `color`,
47
+ `size`, each with an editor and an English and Dutch label for authoring systems), and the candidate types a whole
48
+ number. It shows what every PCI needs:
49
+
50
+ - `getResponse()` reports `{ base: { integer: 30 } }`, or `undefined` while nothing is answered, matching
51
+ `config.tspci.response` in `package.json`;
52
+ - `getState()` and the `state` argument, `setResponse()` and `boundTo` restore an answer;
53
+ - `status: "review"`/`"solution"` make it read-only, and with `"solution"` it shows
54
+ `responseDeclaration.correctResponse`;
55
+ - a labelled input with a focus ring, `aria-invalid` for an answer outside 0-100, and a layout that scales down.
56
+
57
+ | choice | files in `src` | styling |
58
+ | --- | --- | --- |
59
+ | `typescript` | `index.ts` (the PCI), `pie.ts` (the SVG) | `styles.css`, plain CSS in the shadow root |
60
+ | `javascript` | `index.js`, `pie.js` | `styles.css` |
61
+ | `preact+tailwind` | `index.tsx` (the PCI), `interaction.tsx` and `pie.tsx` (components), `store.ts` | Tailwind classes in the components, `styles.css` loads Tailwind |
62
+
44
63
  #### Tailwind
45
64
 
46
- Tailwind is a popular css framework and can be used to give a nice look and feel of the PCI. The cli will make sure the css is added; and all build stuff that comes with tailwind is setup.
65
+ Tailwind is a popular css framework and can be used to give a nice look and feel of the PCI. The cli will make sure the css is added; and all build stuff that comes with tailwind is setup. The components use Tailwind classes directly; the build compiles Tailwind 4 into the bundle and adds it to the shadow root of the PCI, including the defaults its borders, rings and shadows need there.
47
66
 
48
67
  #### preact
49
68
 
@@ -82,6 +101,59 @@ Then build the package:
82
101
 
83
102
  For more info: [@citolab/tspci-qti3](https://github.com/Citolab/tspci/tree/main/lib/tspci-qti3)
84
103
 
104
+ ### Authoring systems
105
+
106
+ An item author places your PCI in an item in an authoring system (qti-construction, TAO), sets it up and decides what
107
+ counts as a correct answer. tspci tells the authoring system how, in `authoring.json`: a build output, written to
108
+ `dist/` by every build and packaged next to the bundle (`resources/pci/authoring.json` in a qti3 package, a file of
109
+ the PCI resource in the manifest, like the SBOM). You write nothing extra; it is generated from two files you already
110
+ have:
111
+
112
+ - **`src/config.json`, the settings.** A value is a plain default (as always), or an object with a `default` and
113
+ hints for the author's form: `editor` (`text`, `textarea`, `integer`, `number`, `boolean`, `enum` with `options`,
114
+ `color`, `colorList`, `hidden`), `label` (one string or one per language), `min`/`max`. The bundle gets only the
115
+ defaults, so `import configProps from "./config.json"` keeps working; type it as `PciProperties<typeof configProps>`.
116
+ - **`package.json` `config.tspci`, the response.** `response` is the variable `getResponse()` fills
117
+ (`{ "cardinality": "single", "baseType": "integer" }`, or a `record` with typed `fields` for a PCI that reports
118
+ several values), `score` the scoring it is made for (`MATCH_CORRECT`, `NONE`), `markup` the elements an author puts
119
+ in the interaction markup (a video, an image).
120
+
121
+ ```jsonc
122
+ // dist/authoring.json (generated)
123
+ {
124
+ "descriptorVersion": "0",
125
+ "typeIdentifier": "blocks",
126
+ "version": "1.0.0",
127
+ "label": "3d blocks",
128
+ "properties": {
129
+ "brickColorMode": { "editor": "enum", "default": "mixed", "options": [{ "value": "mixed" }, { "value": "single" }] },
130
+ "baseplateColor": { "editor": "color", "default": "#4b9f4a" }
131
+ },
132
+ "markup": {},
133
+ "response": { "cardinality": "single", "baseType": "string" },
134
+ "score": ["MATCH_CORRECT", "NONE"]
135
+ }
136
+ ```
137
+
138
+ The qti3 export declares the item's response from `config.tspci.response` (it used to be `single`/`string` always),
139
+ and the build warns about what an authoring system could not use: an unknown editor, an enum default that is not one
140
+ of its options, a record field without a base type.
141
+
142
+ **Scoring.** Authors set the correct answer by answering your PCI in a preview: what `getResponse()` reports becomes
143
+ the correct response, or one of several accepted answers, and scoring compares responses. So report the meaning,
144
+ not the route (the same answer must give the same response), report the declared type, return `undefined` without an
145
+ answer, and show a given response (`setResponse`, `boundTo`, and `responseDeclaration.correctResponse` in
146
+ `status: "solution"`). Margins (a time window, an area) live in the item's response processing, which the authoring
147
+ system writes.
148
+
149
+ **Authoring mode.** A setting with `"editor": "pci"` is edited by the PCI itself: next to `getInstance`, the registered
150
+ object has `getAuthoringInstance(dom, config)`, which returns `{ getConfig, validate?, destroy? }`. An authoring
151
+ system shows a "Configure…" button and runs it in a sandboxed iframe with a small host page
152
+ (`src/authoring-frame.js`). Delivery never calls it and sees ordinary `data-` attributes. The proposal is in
153
+ [`docs/authoring-mode.md`](../../docs/authoring-mode.md).
154
+
155
+ Every project made with `tspci init` has an `AUTHORING.md` that explains all of this with examples and a checklist.
156
+
85
157
  ### SBOM
86
158
 
87
159
  Every production build (`tspci`, `tspci --target ...`) writes a [CycloneDX](https://cyclonedx.org/) 1.6
@@ -292,6 +364,8 @@ Add a `tsconfig.json` to your project for type checking
292
364
 
293
365
  **Try it out** by running `tspci --dev`.
294
366
 
367
+ The dev page needs no internet connection: it loads RequireJS, Bootstrap, preact/htm and qti-components (which runs the PCI the way a QTI 3 player does) from tspci's own dependencies, served under `/vendor/<package>`. The PCI's iframe gets those copies too, so nothing comes from a public CDN.
368
+
295
369
  ### All CLI Options
296
370
 
297
371
  ```
@@ -308,6 +382,7 @@ Available Commands
308
382
  add --target Add specific implementation to the PCI.
309
383
  --no-include-sbom Keep the SBOM out of the exported package (included by default)
310
384
  --no-sbom Do not generate an SBOM for this build
385
+ --no-authoring Keep authoring.json out of the exported package (included by default)
311
386
 
312
387
  Examples package.json scripts
313
388
  $ "dev": "tspci --dev",
@@ -331,12 +406,13 @@ your-pci-project
331
406
  │ tsconfig.json // Typescript configuration, also for type checking your code
332
407
  │
333
408
  └───src
334
- │ │ config.json // Used to configure PCI, and in TAO for authors
409
+ │ │ config.json // The settings: defaults plus editor hints for authors (TAO, authoring.json)
335
410
  │ │ index.ts // The api for the player and bootstraps your interaction
336
- │ │ interaction.ts // the actual interaction, preferably a main preact component
411
+ │ │ interaction.tsx // the actual interaction, preferably a main preact component
337
412
  │ │ store.ts // definition of the state and all possible state mutations of your pci
338
- │ └───style.css // css styles, imported by your pci and possible to use tailwind classes
413
+ │ └───styles.css // css styles, imported by your pci and possible to use tailwind classes
339
414
  │
415
+ │ AUTHORING.md // How authoring systems configure and score your PCI, with a checklist
340
416
  │ postcss.config.js // optionally adds postcss processing of css
341
417
  │ tailwind.config.js // include tailwind classes
342
418
  ```
@@ -415,13 +491,16 @@ module.exports = {
415
491
  ### Bundle all your images
416
492
 
417
493
  Images are bundled in the js by importing them in code:
418
- imports JPG, PNG, GIF, SVG, and WebP files
494
+ imports JPG, PNG, GIF, SVG, and WebP files (`global.d.ts` declares the types for `.png`, `.jpg` and `.svg`)
419
495
 
420
496
  ```ts
421
- import procenten from "./assets/procenten.png";
422
- <img src={procenten} />;
497
+ import logo from "./assets/logo.png"; // a data URL
498
+ <img src={logo} alt="" />;
423
499
  ```
424
500
 
501
+ The build also copies `src/assets` to `dist`. The demo has no images: it draws its circle as SVG from its settings,
502
+ so an author can change it without a new image.
503
+
425
504
  ### Create advanced PCIs with 3D libraries
426
505
 
427
506
  For example, import threejs to create 3D pci's
@@ -449,8 +528,9 @@ dispatch<{ x: number }>("ADD_ACTION", { x: +inputValue });
449
528
 
450
529
  ### Let authors configure your PCI in QTI
451
530
 
452
- Use properties in config.json to let authors configure your PCI
453
- Also used in the TAO export for configuring PCIs in TAO
531
+ Use properties in config.json to let authors configure your PCI. Each one becomes a `data-` attribute of the
532
+ interaction in an item. They are also used in the TAO export, and described for other authoring systems in
533
+ `authoring.json` (see [Authoring systems](#authoring-systems): a value can carry an editor, a label and options).
454
534
  `// src/config.json`
455
535
 
456
536
  ```json
@@ -458,14 +538,14 @@ Also used in the TAO export for configuring PCIs in TAO
458
538
  "buttonText": "Calculate",
459
539
  "sum1": "$1 * 14 + 1",
460
540
  "sum2": "$1 * 2 + 21",
461
- "tableSize": "4"
541
+ "tableSize": { "default": "4", "editor": "integer", "min": 2, "label": "Table size" }
462
542
  }
463
543
  ```
464
544
 
465
545
  ```ts
466
546
  // src/index.ts
467
547
  + import configProps from "./config.json";
468
- + type PropTypes = typeof configProps;
548
+ + type PropTypes = PciProperties<typeof configProps>; // every property arrives as a string
469
549
 
470
550
  // add to types
471
551
  + private config: ConfigProperties<PropTypes>;
@@ -112,6 +112,34 @@ if [[ ! -f "${DIST_TYPES}" ]]; then
112
112
  exit 1
113
113
  fi
114
114
 
115
+ if [[ ! -f "${TARGET_DIR}/AUTHORING.md" ]]; then
116
+ echo ""
117
+ echo "ERROR: expected AUTHORING.md in the generated project" >&2
118
+ exit 1
119
+ fi
120
+
121
+ echo "Verifying the authoring descriptor and the flattened config..."
122
+ node -e "
123
+ const fs = require('fs');
124
+ const path = require('path');
125
+ const dir = process.argv[1];
126
+ const d = JSON.parse(fs.readFileSync(path.join(dir, 'dist', 'authoring.json'), 'utf8'));
127
+ if (d.descriptorVersion !== '0' || !d.typeIdentifier || !d.properties.size || d.properties.size.editor !== 'integer' || d.properties.color.editor !== 'color') {
128
+ console.error('ERROR: unexpected dist/authoring.json', JSON.stringify(d));
129
+ process.exit(1);
130
+ }
131
+ if (d.response.baseType !== 'integer') {
132
+ console.error('ERROR: response not taken from config.tspci.response');
133
+ process.exit(1);
134
+ }
135
+ const bundle = fs.readFileSync(path.join(dir, 'dist', 'index.js'), 'utf8');
136
+ if (bundle.includes('Grootte van de cirkel')) {
137
+ console.error('ERROR: the editor hints of config.json ended up in the bundle');
138
+ process.exit(1);
139
+ }
140
+ console.log('OK: dist/authoring.json is complete and the bundle holds only the defaults');
141
+ " "${TARGET_DIR}"
142
+
115
143
  echo "Running dev startup smoke test (timeout 20s)..."
116
144
  node -e "
117
145
  const { spawn } = require('child_process');
@@ -0,0 +1,85 @@
1
+ // The host side of a PCI's authoring mode: the page an authoring system runs it in (see AUTHORING.md, "Authoring mode").
2
+ //
3
+ // The authoring system puts this page in an iframe, `srcdoc` and `sandbox="allow-scripts"` (no allow-same-origin: the
4
+ // PCI's code gets no access to the editor, its session or its API). Everything else goes by postMessage:
5
+ // frame → host pci-authoring:hello the page is listening
6
+ // host → frame pci-authoring:init { moduleUrl, typeIdentifier, properties, language }
7
+ // frame → host pci-authoring:ready | :change | :error { message }
8
+ // host → frame pci-authoring:request-config the author pressed "Done"
9
+ // frame → host pci-authoring:config { config, problems }
10
+ // The PCI itself never posts messages: it only implements getAuthoringInstance (types: IMSpciAuthoringFactory).
11
+ // Every message carries `version` (AUTHORING_PROTOCOL_VERSION). It only goes up for a change the other side cannot
12
+ // handle; a new optional field keeps it. Each side checks the other's: the frame on init, the host on hello, and a
13
+ // mismatch is an error instead of a dialog that half works.
14
+ // Dependency-free on purpose, so an authoring system can copy or import it.
15
+
16
+ export const AUTHORING_MESSAGE_PREFIX = "pci-authoring:";
17
+ export const AUTHORING_PROTOCOL_VERSION = 1;
18
+
19
+ const escapeAttribute = (value) =>
20
+ String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
21
+
22
+ // Runs inside the frame. Plain ES5, as the PCI bundles it loads.
23
+ const bootstrap = `(function () {
24
+ var VERSION = ${AUTHORING_PROTOCOL_VERSION};
25
+ var host = window.parent;
26
+ var instance = null;
27
+ var started = false;
28
+ function post(message) { message.version = VERSION; host.postMessage(message, "*"); }
29
+ function fail(error) { post({ type: "pci-authoring:error", message: String((error && error.message) || error) }); }
30
+ window.addEventListener("error", function (event) { fail(event.message); });
31
+ function start(init) {
32
+ started = true;
33
+ document.documentElement.lang = init.language || "en";
34
+ define("qtiCustomInteractionContext", [], function () {
35
+ return {
36
+ register: function (factory) {
37
+ if (!factory || typeof factory.getAuthoringInstance !== "function") {
38
+ return fail("This PCI has no authoring mode (getAuthoringInstance).");
39
+ }
40
+ try {
41
+ instance = factory.getAuthoringInstance(document.getElementById("pci"), {
42
+ properties: init.properties || {},
43
+ language: init.language,
44
+ onready: function () { post({ type: "pci-authoring:ready" }); },
45
+ onchange: function () { post({ type: "pci-authoring:change" }); }
46
+ });
47
+ } catch (error) { fail(error); }
48
+ }
49
+ };
50
+ });
51
+ requirejs.config({ paths: { pci: String(init.moduleUrl).replace(/\\.js$/, "") } });
52
+ requirejs(["pci"], function () {}, fail);
53
+ }
54
+ window.addEventListener("message", function (event) {
55
+ if (event.source !== host || !event.data) return;
56
+ if (event.data.type === "pci-authoring:init" && !started) {
57
+ if (event.data.version !== VERSION) return fail("Unsupported authoring protocol version " + event.data.version + " (this page speaks " + VERSION + ").");
58
+ start(event.data);
59
+ }
60
+ else if (event.data.type === "pci-authoring:request-config") {
61
+ if (!instance) return fail("The authoring mode is not ready.");
62
+ try {
63
+ var problems = typeof instance.validate === "function" ? instance.validate() || [] : [];
64
+ post({ type: "pci-authoring:config", config: instance.getConfig(), problems: problems });
65
+ } catch (error) { fail(error); }
66
+ }
67
+ });
68
+ post({ type: "pci-authoring:hello" });
69
+ })();`;
70
+
71
+ /** The frame's HTML (for `iframe.srcdoc`). `requireJsUrl`: RequireJS, absolute (the frame has no origin of its own). */
72
+ export function authoringFrameHtml({ requireJsUrl }) {
73
+ return `<!doctype html>
74
+ <html>
75
+ <head>
76
+ <meta charset="utf-8" />
77
+ <style>html, body { margin: 0; } #pci { padding: 16px; }</style>
78
+ <script src="${escapeAttribute(requireJsUrl)}"></script>
79
+ </head>
80
+ <body>
81
+ <div id="pci"></div>
82
+ <script>${bootstrap}</script>
83
+ </body>
84
+ </html>`;
85
+ }