@citolab/tspci 2.15.0 → 2.16.1

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 CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [2.16.1](https://github.com/Citolab/tspci/compare/v2.16.0...v2.16.1) (2026-11-02)
7
+
8
+ **Note:** Version bump only for package @citolab/tspci
9
+
10
+
11
+
12
+
13
+
14
+ # [2.16.0](https://github.com/Citolab/tspci/compare/v2.15.1...v2.16.0) (2026-11-02)
15
+
16
+ ### Features
17
+
18
+ * **cli:** enhance watcher to persist until dev server stops ([169759b](https://github.com/Citolab/tspci/commit/169759ba66b43c92661de5a9d8f59fcb42a96f0b))
19
+
20
+
6
21
  # [2.15.0](https://github.com/Citolab/tspci/compare/v2.14.0...v2.15.0) (2026-10-17)
7
22
 
8
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citolab/tspci",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
4
4
  "description": "an opiniated typescript ims-pci builder with tao integration",
5
5
  "author": "patrick <patrick.deklein@cito.nl>",
6
6
  "repository": {
@@ -65,5 +65,5 @@
65
65
  "access": "public",
66
66
  "registry": "https://registry.npmjs.org/"
67
67
  },
68
- "gitHead": "6b866b63174a1900c13b607a8a8701d0e2215aff"
68
+ "gitHead": "3787234364e08437ffc5b60210af5343d067b48b"
69
69
  }
package/public/index.html CHANGED
@@ -424,6 +424,82 @@
424
424
  })
425
425
  .catch(() => {});
426
426
 
427
+ // The media slots (authoring.json `markup`, editor "media"): an authoring system with a media bank puts the author's
428
+ // file in qti-interaction-markup. Here you choose one from disk. It stays in this browser (Cache Storage, per PCI), so
429
+ // it is still there after the reload every build does; the PCI gets it as a blob: URL (its iframe is of this origin).
430
+ const devMedia = {}; // slot -> { name, type, url }
431
+ const mediaSlots = () =>
432
+ Object.entries(authoringDescriptor?.markup || {}).filter(([, spec]) => (spec?.editor || "media") === "media");
433
+ const mediaCache = () => caches.open(`tspci-media-${authoringDescriptor?.typeIdentifier || "pci"}`);
434
+ const mediaKey = (slot) => new URL(`/__tspci_media__/${encodeURIComponent(slot)}`, location.href).href;
435
+ const mediaLoaded = authoringLoaded
436
+ .then(async () => {
437
+ if (!mediaSlots().length || !("caches" in window)) return;
438
+ const cache = await mediaCache();
439
+ for (const [slot] of mediaSlots()) {
440
+ const response = await cache.match(mediaKey(slot));
441
+ if (!response) continue;
442
+ const blob = await response.blob();
443
+ const name = decodeURIComponent(response.headers.get("x-file-name") || slot);
444
+ devMedia[slot] = { name, type: blob.type, url: URL.createObjectURL(blob) };
445
+ }
446
+ renderConfigPanel();
447
+ })
448
+ .catch((error) => console.warn("media: could not read Cache Storage", error));
449
+
450
+ async function setMedia(slot, file) {
451
+ const cache = await mediaCache();
452
+ if (devMedia[slot]) URL.revokeObjectURL(devMedia[slot].url);
453
+ if (file) {
454
+ const headers = { "content-type": file.type || "application/octet-stream", "x-file-name": encodeURIComponent(file.name) };
455
+ await cache.put(mediaKey(slot), new Response(file, { headers }));
456
+ devMedia[slot] = { name: file.name, type: file.type, url: URL.createObjectURL(file) };
457
+ } else {
458
+ await cache.delete(mediaKey(slot));
459
+ delete devMedia[slot];
460
+ }
461
+ renderConfigPanel();
462
+ refreshCurrentPci();
463
+ }
464
+
465
+ /** The chosen files as the elements an authoring system writes: its `element`, else what `accept` asks for. */
466
+ function mediaMarkupHtml() {
467
+ return mediaSlots()
468
+ .map(([slot, spec]) => {
469
+ const media = devMedia[slot];
470
+ if (!media) return "";
471
+ const element =
472
+ spec.element || (spec.accept || []).map((type) => type.split("/")[0]).find((kind) => kind === "video" || kind === "audio") || "img";
473
+ const src = escapeHtmlAttribute(media.url);
474
+ return element === "img"
475
+ ? `<img src="${src}" alt="${escapeHtmlAttribute(media.name.replace(/\.[^.]*$/, ""))}">`
476
+ : `<${element} src="${src}" controls></${element}>`;
477
+ })
478
+ .join("");
479
+ }
480
+
481
+ function MediaControls() {
482
+ const slots = mediaSlots();
483
+ if (!slots.length) return null;
484
+ return html`<div class="mb-3 rounded border border-dashed p-2">
485
+ ${slots.map(([slot, spec]) => {
486
+ const media = devMedia[slot];
487
+ return html`<div class="mb-2">
488
+ <label class="form-label text-muted mb-1" title="markup: ${slot}">${authoringText(spec.label, slot)}${spec.required ? " *" : ""}</label>
489
+ <input type="file" class="form-control form-control-sm" accept="${(spec.accept || []).join(",")}"
490
+ onChange="${(e) => e.target.files[0] && setMedia(slot, e.target.files[0])}" />
491
+ ${media
492
+ ? html`<div class="d-flex align-items-center gap-2 small mt-1">
493
+ <code class="text-truncate" title="${media.name}">${media.name}</code>
494
+ <button type="button" class="btn btn-sm btn-link p-0" onClick="${() => setMedia(slot, null)}">remove</button>
495
+ </div>`
496
+ : html`<div class="small text-muted mt-1">None: the markup has no ${spec.element || slot}.</div>`}
497
+ </div>`;
498
+ })}
499
+ <p class="small text-muted mb-0">Goes in <code>qti-interaction-markup</code>, as an authoring system puts it there. The file stays in this browser.</p>
500
+ </div>`;
501
+ }
502
+
427
503
  const authoringText = (text, fallback) =>
428
504
  !text ? fallback : typeof text === "string" ? text : text.en || Object.values(text)[0] || fallback;
429
505
  const toHex = (c) => {
@@ -562,6 +638,7 @@
562
638
  : ""}`
563
639
  : html`No <code>authoring.json</code> yet: build once, or see AUTHORING.md`}
564
640
  </p>
641
+ <${MediaControls} />
565
642
  <${AuthoringControls} />
566
643
  <fieldset disabled=${!!authoringSession}>
567
644
  <form onSubmit="${(e) => e.preventDefault()}">
@@ -930,7 +1007,7 @@
930
1007
  <qti-interaction-module id="pci" primary-path="./index"></qti-interaction-module>
931
1008
  </qti-interaction-modules>
932
1009
  <qti-interaction-markup>
933
- <div data-qti-pci-runtime-host style="height:100%"></div>
1010
+ <div data-qti-pci-runtime-host style="height:100%"></div>${mediaMarkupHtml()}
934
1011
  </qti-interaction-markup>
935
1012
  </qti-portable-custom-interaction>
936
1013
  </qti-item-body>
@@ -964,7 +1041,7 @@
964
1041
  if (typeof globalThis.off !== "function") {
965
1042
  globalThis.off = () => {};
966
1043
  }
967
- await Promise.all([loadQtiComponents(), authoringLoaded]);
1044
+ await Promise.all([loadQtiComponents(), authoringLoaded, mediaLoaded]);
968
1045
  loadItem();
969
1046
  };
970
1047
  </script>
package/src/cli.js CHANGED
@@ -128,7 +128,10 @@ const watchBuild = (devServer = false, target = "IMS") => {
128
128
  result.close();
129
129
  }
130
130
  });
131
- watcher.close();
131
+ // keep watching until the dev server stops (closing it here cancels the first build)
132
+ const closeWatcher = () => watcher.close();
133
+ process.on("SIGINT", closeWatcher);
134
+ process.on("SIGTERM", closeWatcher);
132
135
  });
133
136
  };
134
137