@pie-players/pie-item-player
v0.3.73
Published
Unified custom element for rendering PIE items with iife/esm/preloaded strategies
Readme
@pie-players/pie-item-player
Unified custom element for rendering PIE assessment items. A single <pie-item-player> tag handles delivery, evaluation, and authoring through a mode attribute, and supports multiple loading strategies (iife, esm, preloaded).
This package replaces the legacy @pie-framework/pie-player-components project, which required separate <pie-player> and <pie-author> elements. See migration guide for details.
Install
npm install @pie-players/pie-item-player
# or
bun add @pie-players/pie-item-playerOr load from a CDN:
<script type="module" src="https://cdn.jsdelivr.net/npm/@pie-players/pie-item-player/dist/pie-item-player.js"></script>The script self-registers the <pie-item-player> custom element.
Content styles
Authored content relies on shared classes that belong to no single component:
passage markup (.numbered-paragraph, .p-number, div.passage-title /
-subtitle / -author), the legacy kds-* content classes, and the
pie-answer-eliminator-* / pie-answer-masked-* families. They live in
@pie-players/pie-theme/components.css.
The player installs that stylesheet itself. Importing the element is all a host needs:
import "@pie-players/pie-item-player";The stylesheet is bundled into the player as text and installed once per
document, at import time, before any instance renders. It is prepended to
<head> so host CSS that comes later still wins at equal specificity — the
placement hosts used to be told to set up by hand. Installation is idempotent, so
loading several player packages, or several copies of one, yields a single copy.
This applies to CDN hosts too: no extra <link> is needed.
Upgrading from a manual import
If your app already imports @pie-players/pie-theme/components.css, you do not
have to remove it for this to work — installation is idempotent and two matching
copies render identically. Removing it is still worth doing: your copy loads after
the installed one and so wins ties at equal specificity, which means a copy pinned
to an older @pie-players/pie-theme will quietly override newer player rules. The
player logs a one-time warning naming the redundant import when it sees a second
copy, so this does not have to be remembered.
Taking ownership of the stylesheet
To load it yourself instead — to control its position in your cascade, or to ship a patched copy — declare that on the root element before the player script runs:
<html data-pie-content-styles="host"></html>import "@pie-players/pie-theme/components.css"; // now your responsibilityThe player then installs nothing. If no content stylesheet turns out to be
present, it logs a one-time console.warn naming the missing import, rather than
silently rendering unstyled content. Presence is detected through
--pie-content-styles, a sentinel components.css declares; it carries no
themeable value, so do not consume it for styling or depend on anything beyond it
being non-empty. Declare @pie-players/pie-theme in your own
package.json if you go this route: it is a dependency of this package, so the
file is already on disk, but importing a subpath from a transitive dependency
breaks on a dedupe change or a move to pnpm / Yarn PnP.
This stylesheet is only the shared content styles. See
@pie-players/pie-theme for --pie-* tokens, the
<pie-theme> host element, and color-scheme / font-size theming — all optional
for correct rendering, since every var(--pie-*) in components.css has a
fallback.
Runtime boundary and migration
- Browser-only package:
@pie-players/pie-item-playeris a DOM custom-element runtime package and is not intended for plain Node runtime imports. - Node-import-safe packages (for server/runtime utilities) are documented in
docs/setup/library-packaging-strategy.md. - Migration direction:
- Legacy migration from
@pie-framework/pie-player-componentsremains documented. - For current hosts, prefer the stable default entrypoint:
- Legacy migration from
import "@pie-players/pie-item-player";Use explicit component subpath exports only when you need targeted registration control (for example the session debugger element export).
Standalone browser variants for this package are intentionally deferred; current
support targets default bundler entrypoints under dist.
Quick start
<pie-item-player
strategy="iife"
config='{"elements":{"my-el":"[email protected]"},"models":[{"id":"1","element":"my-el"}],"markup":"<my-el id=\"1\"></my-el>"}'
env='{"mode":"gather","role":"student"}'
session='{"id":"s1","data":[]}'
></pie-item-player>Custom elements
pie-item-player- Export:
@pie-players/pie-item-player - Description: main player element
- Export:
pie-item-player-session-debugger- Export:
@pie-players/pie-item-player/components/item-session-debugger-element - Description: floating debug panel showing live session and filtered model data
- Export:
Attributes
config:Object, defaultnull. Item config withelements,models, andmarkupfields.session:Object, default{ id: "", data: [] }. Session container, kept live the way<pie-player>'s was: the player writes an entry per model into this object atload-completeand each change into that entry before it dispatchessession-changed, preserving the array's and the entries' identity and leaving entries it did not produce alone. Inside an entry the player's session wins and a key it does not carry is removed, so a cleared response clears here too. A frozen container is left untouched, and a section player hands each item a per-render copy, sodetail.sessionon the event is the authoritative payload;ItemControllerowns the session and never reads this object back after the first load.env:Object, default{ mode: "gather", role: "student" }. Environment mode and role.strategy:String, default"iife". Loading strategy:"iife","esm", or"preloaded".mode:String, default"view". Player mode:"view"or"author".authoring-backend:String, default"demo"."demo"uses built-in stubs;"required"requires host-provided handlers.hosted:Boolean, defaultfalse. Whether running in hosted mode; affects IIFE bundle type.add-correct-response:Boolean, defaultfalse. Populate correct responses on models.show-bottom-border:Boolean, defaultfalse. Add bottom border in evaluate mode.autoplay-audio-enabled:Boolean, default unset (each model's ownautoplayAudioEnabledvalue is left as-is). When set, forcesautoplayAudioEnabledonto every model in the config, overwriting whatever each model declares (or adding the field if a model doesn't declare it at all). To force it tofalse, set it as a JS property (player.autoplayAudioEnabled = false) instead of using this attribute — a literalfalsevalue passed through an HTML attribute can be misread astrueonce it reaches the custom element.debug:String, default"". Truthy values enable verbose logs;"false","0", and""disable them. Also readswindow.PIE_DEBUG.custom-class-name:String, default"". CSS scope class applied to the player container.container-class:String, default"". Extra class on the inner item container.external-style-urls:String, default"". Comma-separated CSS URLs loaded and scoped to the player. URLs must behttp:orhttps:, and same-origin unless their origin is named inallowed-style-origins.allowed-style-origins:String, default"". Comma-separated origin allow-list forexternal-style-urlsanditemConfig.resources.stylesheets[*].url. Unset, only same-origin stylesheets load. Naming an origin opts into cross-origin CSS, which the player cannot scope to the item: a cross-origin sheet is applied by the browser from a<link>in<head>rather than fetched and rewritten, so its rules reach the whole page.loader-config:Object, default package config. Loader instrumentation config.configuration:Object, default{}. Authoring configuration settings. Useconfiguration.authoringfor authoring-only settings.trust-markup:Boolean, defaultfalse. Skip the built-in markup sanitizer. See Content trust boundary.
Properties (JS only)
These are set via JavaScript, not HTML attributes.
loaderOptions:{ bundleHost?: string, esmCdnUrl?: string, esmCdnProvider?: string | object, moduleResolution?: "url" | "import-map", view?: string, loadControllers?: boolean, runtimeSupportCheck?: "off" | "on" }. Strategy-specific loader options. For ESM, the default provider is jsDelivr (https://cdn.jsdelivr.net/npm); useesmCdnProvider: "esm.sh"withesmCdnUrl: "https://esm.sh"for esm.sh, or pass a provider object when package artifacts and shared dependencies use custom routes.sanitizeMarkup:(markup: string) => string. Replace the built-in DOMPurify sanitizer with a host-supplied function. Ignored whentrust-markupis set.backend: JS-only backend integration namespace. Usebackend.deliveryfor API-backed item/session load, autosave, explicit save, and server scoring. Existing player inputs such asenv,strategy,loaderOptions,config, andsessionremain top-level player properties.A host-supplied
backend.delivery.clientreceivescontext.requestOptions.keepaliveon the unload-path save and has to forward it tofetch. Without that the save is an ordinary request the browser may drop as the document goes away.
Methods
provideScore(): Promise<false | Array<Record<string, unknown> | undefined>>returns one result slot per scored model for legacy-compatible local browser scoring.updateElementModel(update): Promise<void>applies a legacy-compatible preview update for a single loaded PIE model.validateModels(): Promise<AuthoringValidationResult>runs authoring-mode validation for rendered configure elements and returns{ hasErrors, validatedModels }.loadFromBackend(scope?: "delivery" | "authoring"): Promise<void>loads configured backend data into the existing player config/session pipeline.saveSession(): Promise<void>persists the current session throughbackend.delivery.score(options?): Promise<unknown>performs server-backed scoring throughbackend.delivery. This is intentionally separate from localprovideScore().commitPendingElementSessions(): voidcommits every mounted element's pendingsession-changednow. A host that unmounts the player itself calls this first: the player's own destroy runs after the element is detached, so the event it produces reaches the player element but notdocument.
Events
load-complete: emitted when PIE elements finish loading.session-changed:{ session, ... }. Emitted when the student interacts and session data changes. A commit at a teardown, navigation or page-hidden seam carriesdetail.sessionCommitReason("teardown" | "navigate" | "page-hidden"). A host that re-pushesconfigin response to this event should ignore a commit, since the commit exists to report a response the host is about to lose rather than to request a reload.player-error:{ code?, message?, stage?, strategy?, mode? }. Error event, for exampleAUTHORING_BACKEND_CONFIG_ERRORorITEM_PLAYER_LOAD_ERROR.model-updated: emitted when a PIE element model is updated.model-loaded:{ models, configuration }. Authoring lifecycle event emitted once per renderer initialization after configure elements receive models and configuration.backend-load-complete: emitted afterbackend.deliveryloads config/session data.backend-session-saved: emitted aftersaveSession()or delivery autosave persists successfully.backend-score-complete: emitted after server-backedscore()completes.backend-error: emitted when backend load/save/score fails.correct-responses-populated:{ itemId?, mode?, role?, bundleType?, populatedCount, elements }. Emitted when correct responses were written into the session.elementsholds theconfig.models[].elementnames (for examplemultiple-choice--version-latest), not rendered tag names. The detail carries counts and those names only, never session entries.This exists so a host can detect a population it did not ask for. Prevention is not available here:
add-correct-response,envandmodeare public attributes, so any page script can set them, and a legitimate preview (mode="view",role="student", controllers client-side) is indistinguishable from a tampered delivery from inside the player. The event firing at all is the signal, because population needs a controller withcreateCorrectResponseSessionin the browser, which only aclient-player.jsbundle provides.hosted="true"— which selects theplayer.jsbundle and leaves controllers to the host — is the actual boundary for proctored delivery. See Loading strategies.Also forwarded to a configured instrumentation provider as
pie-item-correct-responses-populated. It is the only item-player event on that bridge:session-changedcarries learner responses, and forwarding those to telemetry by default is a host's decision rather than this package's.
Backend delivery
Backend support is a JS-only namespace for networking and persistence. It does
not duplicate existing delivery inputs such as env, strategy,
loaderOptions, bundleEndpoints, or styling props.
const el = document.querySelector("pie-item-player");
el.env = { mode: "gather", role: "student" };
el.strategy = "iife";
el.loaderOptions = { bundleHost: "https://proxy.pie-api.com/bundles/" };
el.backend = {
delivery: {
enabled: true,
provider: "pie-api",
itemId: "item-1",
sessionId: "session-1",
autosave: { enabled: true, debounceMs: 250 },
endpoints: {
load: "/api/player/load",
saveSession: "/api/player/save",
model: "/api/player/model",
score: "/api/player/score",
},
},
};For a runnable local backend demo, see
docs/item-player/backend-support.md.
PIE Element Packaging Contract
The canonical producer-side contract for @pie-element/* packages lives in the
pie-elements-ng package contract.
<pie-item-player strategy="esm"> assumes that contract is satisfied:
- Element packages publish static browser ESM files such as
dist/browser/delivery/index.js,dist/browser/author/index.js, anddist/browser/controller/index.js. The player imports those files directly; it does not transform element package entrypoints through CDN+esmroutes. - React-backed browser ESM packages declare exact shared browser singleton
versions in
package.jsonunderpie.browserSharedDependencies; dependency and peer-dependency ranges are not used as fallback runtime contracts. ./runtime-supportmetadata is optional for ESM-capable packages unless they need to disable a runtime strategy or view. SetloaderOptions.runtimeSupportCheck = "on"when you want the player to read those hints before loading.strategy="preloaded"is not a separate package shape. It means the host has already registered the versioned custom element tag before the player renders.
Authoring configuration
When mode="author", <pie-item-player> loads editor/config elements and
passes each configure element a resolved configuration object.
Delivery/shared settings stay at the top level of configuration and may be
keyed by package spec, package name, or element tag. Authoring-only settings
belong under configuration.authoring so they do not affect delivery mode.
Authoring keys resolve by specificity:
- Full versioned PIE tag, for example
multiple-choice--version-1-2-3 - Package spec, for example
@pie-element/[email protected] - Package name, for example
@pie-element/multiple-choice - Package base name, for example
multiple-choice
Top-level delivery/shared settings are merged first, then matching
configuration.authoring settings override them for authoring only.
const el = document.querySelector("pie-item-player");
el.mode = "author";
el.configuration = {
"@pie-element/[email protected]": {
sharedSetting: true,
},
authoring: {
"multiple-choice--version-1-2-3": {
authoringOnlySetting: true,
},
},
};Authoring media hooks
When mode="author", the player supports image and sound upload/delete through four handler properties:
onInsertImage(handler: ImageHandler): voidonDeleteImage(src: string, done: (err?: Error) => void): voidonInsertSound(handler: SoundHandler): voidonDeleteSound(src: string, done: (err?: Error) => void): void
With authoring-backend="demo", built-in demo handlers are used. Set authoring-backend="required" to enforce that the host provides all four handlers; missing handlers will block the authoring UI and emit a player-error.
const el = document.querySelector("pie-item-player");
el.mode = "author";
el.authoringBackend = "required";
el.onInsertImage = (handler) => {
handler.done(undefined, "https://example.com/uploaded-image.png");
};
el.onDeleteImage = (_src, done) => done();
el.onInsertSound = (handler) => {
handler.done(undefined, "https://example.com/uploaded-sound.mp3");
};
el.onDeleteSound = (_src, done) => done();Exported types
import type {
PieItemPlayerElement,
PieItemSessionDebuggerElement,
AuthoringBackendMode,
ImageHandler,
SoundHandler,
DeleteDone,
AuthoringValidationResult,
} from "@pie-players/pie-item-player";Content trust boundary
<pie-item-player> injects the markup field from the supplied config
(and from passageConfig.markup when a passage is attached) into the DOM
via Svelte's {@html} directive. To avoid XSS when hosts embed
attacker-influenced item/passage JSON (preview surfaces, multi-tenant
authoring, etc.) the player now ships with a default-on markup
sanitizer powered by DOMPurify. The
sanitizer:
- Strips
<script>,<iframe>,<object>,<embed>,<base>,<form>,<meta>,<link>,<foreignObject>,<style>, and any event-handler attributes (onerror,onload, ...). - Rejects unknown URL protocols (
javascript:,data:unless explicitly marked safe). - Filters
styleattributes per declaration, dropping URL-fetching functions (url(),image-set(),src()) andposition: fixed. - Preserves the PIE custom-element contract: any tag matching
pie-*and the attribute familiesdata-*,aria-*,pie-*,model-*,session-*,config-*,context-*pass through unchanged. Third-party custom elements must be registered via thesanitizeMarkupproperty.
The limits this sanitizer deliberately accepts — chiefly authored
position: absolute overlays, which light DOM does not contain — and the host
obligations that go with them are in
docs/security/readme.md.
Opt out (trusted content)
Hosts that already validate item markup (for example, content-authoring pipelines that only ever render markup produced by their own trusted servers) can disable sanitization:
<pie-item-player trust-markup config='...' session='...'></pie-item-player>el.trustMarkup = true;Provide a custom sanitizer
To extend the allow-list (or use a stricter sanitizer) set
sanitizeMarkup on the element:
import { sanitizeItemMarkup } from "@pie-players/pie-players-shared/security";
el.sanitizeMarkup = (markup: string) =>
sanitizeItemMarkup(markup, {
allowedCustomElements: ["my-custom-widget"],
});When trust-markup is set, sanitizeMarkup is ignored.
