@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.
- package/CHANGELOG.md +28 -0
- package/package.json +14 -4
- package/preset/base/AUTHORING.md +171 -0
- package/preset/base/global.d.ts +12 -2
- package/preset/base/package.json +10 -1
- package/preset/base/src/config.json +37 -5
- package/preset/base/src/styles.css +96 -3
- package/preset/base/tsconfig.json +4 -3
- package/preset/javascript/src/index.js +116 -114
- package/preset/javascript/src/pie.js +46 -0
- package/preset/preact/src/index.tsx +115 -113
- package/preset/preact/src/interaction.tsx +47 -34
- package/preset/preact/src/pie.tsx +46 -0
- package/preset/preact/src/store.ts +11 -14
- package/preset/tailwind/src/styles.css +4 -27
- package/preset/typescript/src/index.ts +123 -127
- package/preset/typescript/src/pie.ts +51 -0
- package/public/index.html +196 -51
- package/readme.md +91 -11
- package/release-notes.md +5 -0
- package/scripts/test-init-local.sh +28 -0
- package/src/authoring-frame.js +85 -0
- package/src/authoring.js +114 -0
- package/src/rollup-plugin-authoring.js +32 -0
- package/src/rollup-plugin-postcss-string.js +37 -1
- package/src/rollup.config.dev.js +18 -1
- package/src/rollup.config.js +2 -0
- package/tspci-development.png +0 -0
- package/types/index.d.ts +136 -0
- package/preset/base/src/assets/procenten.png +0 -0
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 "
|
|
212
|
+
} from "preact";
|
|
207
213
|
import {
|
|
208
214
|
useState,
|
|
209
215
|
useEffect,
|
|
210
216
|
useRef,
|
|
211
|
-
} from "
|
|
212
|
-
import htm from "
|
|
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)
|
|
420
|
-
|
|
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-
|
|
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-
|
|
432
|
-
<label class="form-label text-muted mb-1">${key}</label>
|
|
433
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
"
|
|
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
|
-
|
|
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}"
|
|
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 //
|
|
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.
|
|
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
|
-
│ └───
|
|
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
|
|
422
|
-
<img src={
|
|
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
|
-
|
|
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>;
|
package/release-notes.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# 2.12.1
|
|
2
|
+
|
|
3
|
+
- `tspci init` now writes a tsconfig that is ready for TypeScript 6: an explicit `rootDir` of `./src`, `moduleResolution: "bundler"` instead of the deprecated node10 resolution, `module: "esnext"` and `lib: es2017`
|
|
4
|
+
- existing PCIs keep their own tsconfig.json, apply the same four changes there to silence the TypeScript 6 warnings
|
|
5
|
+
|
|
1
6
|
# 2.12.0
|
|
2
7
|
|
|
3
8
|
- the SBOM is now included in the exported package by default, next to the bundle it describes: `resources/pci/sbom.cdx.json` (qti3), `interaction/runtime/js/sbom.cdx.json` (tao), `ref/script/sbom.cdx.json` (qbci)
|
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|