<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[pipwerks]]></title><description><![CDATA[Tech, web, and e-learning nerdery]]></description><link>https://pipwerks.com/</link><image><url>https://pipwerks.com/favicon.png</url><title>pipwerks</title><link>https://pipwerks.com/</link></image><generator>Ghost 5.118</generator><lastBuildDate>Wed, 10 Jun 2026 01:08:28 GMT</lastBuildDate><atom:link href="https://pipwerks.com/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[A note on iframes]]></title><description><![CDATA[<p><a href="https://pipwerks.com/persistent-api-connection/" rel="noreferrer">The last entry in the SCORM for Developers series</a> discussed iframes versus AJAX and reactive frameworks. I&apos;d like to provide some historical context for iframes in SCORM courses.</p><p>The SCORM documentation and early SCORM examples have a heavy emphasis on framesets because SCORM 1.0 was released in</p>]]></description><link>https://pipwerks.com/a-note-on-iframes/</link><guid isPermaLink="false">6904641e907e560001b58bd7</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Fri, 31 Oct 2025 07:38:59 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://pipwerks.com/persistent-api-connection/" rel="noreferrer">The last entry in the SCORM for Developers series</a> discussed iframes versus AJAX and reactive frameworks. I&apos;d like to provide some historical context for iframes in SCORM courses.</p><p>The SCORM documentation and early SCORM examples have a heavy emphasis on framesets because SCORM 1.0 was released in 2000, only three years after HTML 4 (1997).</p><p>The authors and early adopters of SCORM used traditional framesets in their SCORM courses, because that&#x2019;s simply how things were done at the time.&#xA0;</p><p>I started building websites in 1994/95. Back then, if you wanted a sidebar containing site navigation on every page of your site, but didn&#x2019;t want to copy and paste your markup into all of your site files, the easiest solution was to use a frameset.</p><p>Wordpress and content management systems didn&#x2019;t exist yet, and most people were writing their markup by hand. Inline frames, commonly referred to as iframes, had been supported by Internet Explorer for a while, but had only been committed as a cross-browser standard via the HTML 4.01 spec in 1999. The original &lt;frameset&gt; was still very common.&#xA0;</p><p>Learning management systems used traditional framesets to display SCORM packages, typically with the table of contents in a sidebar frame on the left, and the main content in a larger frame on the right. A header or footer might be included via a third frame. </p><p>Traditional framesets were clunky, suffered from accessibility issues, and gradually grew out of favor. As CSS and JavaScript support improved within browsers, web developers started to rely on dynamically loaded content (AJAX) and the occasional iframe. Eventually, traditional framesets developed a bad reputation and &#x201C;frame&#x201D; became a dirty word.</p><p>By 2014, the HTML5 spec made it official &#x2014; the &lt;frameset&gt; element was deprecated. But the &lt;iframe&gt; lives on as part of the HTML5 spec, and is ubiquitous. Iframes power much of the dynamic web, including millions of widgets, including Twitter tweets, Instagram posts, YouTube embeds, and sign-up forms. The &lt;iframe&gt; is widely accepted part of the modern web, and is still a great option.&#xA0;<strong>30 years after the invention of the iframe, every LMS still uses frames (most commonly an iframe) to display SCORM courses.</strong></p><p>In e-learning, the iframe is typically configured to expand vertically and horizontally to fill the parent HTML document, obscuring the parent HTML from view, and giving the appearance of a single HTML file.</p><p>With this in mind, please don&#x2019;t feel guilty for using an &lt;iframe&gt; in your SCORM courseware. Iframes are perfectly legit, support modern responsive design techniques, and are accessible (if built correctly). I can guarantee you that your LMS is loading your course into an &lt;iframe&gt;, anyway.</p>]]></content:encoded></item><item><title><![CDATA[Persistent API Connection]]></title><description><![CDATA[<p>The last lesson in the SCORM for Developers series covered <a href="https://pipwerks.com/using-a-scorm-wrapper-to-simplify-the-workflow/" rel="noreferrer">SCORM wrappers</a>. </p><p>All of the code we&apos;ve examined so far has been for single-page &apos;courses&apos; (I use that term loosely). We&#x2019;ve added our SCORM initialization code, and the course launches as expected. But what</p>]]></description><link>https://pipwerks.com/persistent-api-connection/</link><guid isPermaLink="false">69045817907e560001b58b32</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Fri, 31 Oct 2025 07:38:12 GMT</pubDate><content:encoded><![CDATA[<p>The last lesson in the SCORM for Developers series covered <a href="https://pipwerks.com/using-a-scorm-wrapper-to-simplify-the-workflow/" rel="noreferrer">SCORM wrappers</a>. </p><p>All of the code we&apos;ve examined so far has been for single-page &apos;courses&apos; (I use that term loosely). We&#x2019;ve added our SCORM initialization code, and the course launches as expected. But what do we do if we&#x2019;d like to add a second page? What happens if we add a &#x2018;next&#x2019; button for the learner that automatically navigates from the first page to the second page?&#xA0;</p><p>Believe it or not, navigating away from the first page would cause the course to stop dead in its tracks and cease reporting progress to the LMS. The SCORM API connection would be severed before the browser loads the second page into the browser window. The course session would be terminated, and any action taken on the second page would <strong>not</strong> be reported to the SCORM API.</p><p>Why?</p><p>Navigating away from the first page will cause the browser to unload all of the HTML, JavaScript, and other assets belonging to the first page. The browser will clear the table, metaphorically speaking, before serving the second page. Any assets that the two pages may have shared, such as scripts, stylesheets, or images, would have to be re-loaded.</p><p>Any JavaScript belonging to the first page, including the SCORM API connection, will stop running when the page unloads.The second page has no knowledge of the existence of the first page.&#xA0;</p><figure class="kg-card kg-image-card"><img src="https://pipwerks.com/content/images/2025/10/Maintain-API-Connection.png" class="kg-image" alt="Diagram illustrating navigating from one HTML page to another" loading="lazy" width="1800" height="532" srcset="https://pipwerks.com/content/images/size/w600/2025/10/Maintain-API-Connection.png 600w, https://pipwerks.com/content/images/size/w1000/2025/10/Maintain-API-Connection.png 1000w, https://pipwerks.com/content/images/size/w1600/2025/10/Maintain-API-Connection.png 1600w, https://pipwerks.com/content/images/2025/10/Maintain-API-Connection.png 1800w" sizes="(min-width: 720px) 720px"></figure><p>Furthermore, the LMS likely monitors whether the first HTML file has unloaded, and will terminate the SCORM session because it knows the SCORM API connection has been severed.</p><p>You may be thinking &#x201C;OK, so I just run LMSInitialize again. What&#x2019;s the big deal?&#x201D; Unfortunately, it&#x2019;s not that simple &#x2014; SCORM only allows LMSInitialize to run once per course launch, so you will not be able to re-initialize the session without closing the course window and relaunching via the LMS interface.&#xA0;</p><p>The trick for SCORM courses is to <strong>never unload the HTML page that performs the initial handshake</strong>. This page will maintain the SCORM connection throughout the course session. You&#x2019;d then use this single HTML page as a &#x2018;parent&#x2019; for your course, and have all subsequent content load into this file dynamically. As you navigate the content in the course, the parent HTML remains intact, never breaking the SCORM API connection.&#xA0;</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pipwerks.com/content/images/2025/10/Dynamically-loaded-content--generic-.png" class="kg-image" alt loading="lazy" width="415" height="266"><figcaption><span style="white-space: pre-wrap;">Illustration of dynamic content loaded into a parent HTML page</span></figcaption></figure><p>There are several techniques for dynamically displaying content including toggling elements in a single monolithic HTML page, dynamically loading external content via AJAX, and using HTML framesets.</p><h2 id="monolithic-html-pages">Monolithic HTML Pages</h2><p>The simplest way to ensure your API connection remains active is to only use a single HTML page. For example, a slideshow system like <a href="https://revealjs.com/?ref=pipwerks.com" rel="noreferrer">Reveal.js</a> places all content into &lt;section&gt; elements, then dynamically toggles their visibility as the visitor navigates the presentation. The page itself is never reloaded, which means the SCORM connection is never broken. </p><p>I will demonstrate how to use Reveal.js to build a course in an upcoming lesson. </p><h2 id="frames">Frames</h2><p>The original technique recommended by the authors of SCORM is the traditional frameset, where an HTML file serves as a parent and loads HTML files as-needed into a child frames. In this scenario, the learner can navigate between child frames without unloading the parent frame. The child frames use JavaScript to send data to the parent frame, which keeps track of all course progress, and, in turn, reports the progress to the LMS via the SCORM API.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pipwerks.com/content/images/2025/10/Maintain-API-Connection-3.png" class="kg-image" alt loading="lazy" width="600" height="423" srcset="https://pipwerks.com/content/images/2025/10/Maintain-API-Connection-3.png 600w"><figcaption><span style="white-space: pre-wrap;">Illustration of an HTML page containing child frames</span></figcaption></figure><p>In my opinion, frames are still the best solution, though the HTML5 specification deprecates &lt;frameset&gt; in favor of &lt;iframe&gt;.&#xA0;</p><h2 id="ajax-and-reactive-frameworks">AJAX and Reactive Frameworks</h2><p>AJAX is a JavaScript technique utilizing a JavaScript&#x2019;s xmlhttprequest (XHR) to load remote content. xmlhttprequest was created by Microsoft. It first appeared in Internet Explorer, and by 2005 had been adopted by other browsers. It would not be an overstatement to say AJAX completely transformed the World Wide Web and how we build websites today.</p><p>Prior to AJAX, if you wanted to change what is displayed on a page, such as a confirmation that a purchase was successful, you&#x2019;d use server-side code to modify the HTML file, then completely reload the webpage to display the updated content. The AJAX technique uses JavaScript to fetch remote content (via XHR) then insert the content into the current HTML <em>without requiring a page reload</em>.&#xA0;</p><p>The ability to alter page content without a page reload is why AJAX is a compelling option for building e-learning courses. If the page is never reloaded, the SCORM API connection remains intact.</p><p>A very famous example of an AJAX-based system is Google&#x2019;s Gmail; the inbox you see when you open Gmail is one big HTML file. When you click a message in your inbox, xmlhttprequest is used to fetch the message&#x2019;s content from the mail server, then JavaScript is used to merge the content into Gmail&#x2019;s HTML and display the message on screen. All without requiring a page reload.&#xA0;</p><p>When you close the message, JavaScript is used to hide the message and reveal your inbox again, without navigating away from the original HTML. You can view hundreds of messages without ever unloading the inbox you first saw when you opened Gmail.</p><p>AJAX is incredibly powerful, and is implemented in just about every major website you can think of. AJAX is best suited for systems that have clearly defined templates, such as Gmail&#x2019;s inbox and message screens.&#xA0;</p><h3 id="reactive-frameworks">Reactive Frameworks</h3><p>Modern implementations of AJAX are reactive frameworks such as React, Angular, and Vue. These frameworks are AJAX on steroids, providing incredibly powerful features like reactive variables, reusable components, and the ability to quickly apply visual themes across the project.</p><p>As powerful as these systems are, they require a lot of time and careful planning to build a course framework and templates. Once you&#x2019;ve built your templates, you&apos;re fairly locked in. In my experience, I found these kinds of templates less flexible over time, adding more technical debt and becoming less flexible as a result. For example, if I found or created a new JavaScript-powered interaction, I couldn&apos;t just plop it in the course &#x2013; I would have to build an accompanying template, including sorting out the data fields and how to pass them to the interaction.</p><p>Having said that, a templated system utilizing reactive frameworks or traditional AJAX might be the perfect fit if your course will only need a handful of layouts or interactions!</p><p>These templatized systems can be very practical for large organizations who need to enforce specific standards across their courses. For example, templates allow a visual design team to lock the look and feel, branding, and any other visual style guidelines into place, ensuring a consistent, professional presentation regardless of who authors the course. Templates can enforce specific instructional pedagogies, which might be good if you have a very large team of instructional designers dispersed across the globe.</p><p>Templates are also clearly beneficial for e-learning development tool vendors, because a template can be throughly tested before release and easy supported afterward.&#xA0;</p><p>For better or for worse, a template-based system essentially boils down to this: No flexibility means no surprises. You know ahead of time how it will look, you know ahead of time how it will behave, and you probably even know how long it will take to build a course because you&#x2019;re basically just filling out forms.&#xA0;</p><p>But this also underscores the price you pay when locking yourself into templates &#x2014; a tremendous loss of agility when creating new layouts and interactions, and a ton of up-front work if you design your own template-based system. </p><p>If you&apos;ve used a commercial e-learning development tool like Articulate Rise, you should be familiar with the limitations: What they give you is what you get, end of story. You are agreeing to be locked in to their system and their templates.&#xA0;</p><p>Ultimately, this lack of flexibility is why I recommend using iframes instead of AJAX or reactive frameworks.</p><p>In iframe-based systems, your course is simply a collection of HTML pages. Each page is a blank canvas for whatever inspiration strikes you on a given day. Anything you can do with an HTML page can be done in your course. You are not locked in to a strict template system (though you can certainly make some templates to save yourself time in the future), and you can quickly try new ideas and iterate on them without having to worry about a complicated backend.</p><p>If you&#x2019;ve set up your course&#x2019;s tracking code to be reusable, such as what we&#x2019;ve done in our prior examples, you can adapt almost any HTML-based interaction or content to work within your courses. And you can do it within minutes, not days or months.</p>]]></content:encoded></item><item><title><![CDATA[Using a SCORM Wrapper to Simplify the Workflow]]></title><description><![CDATA[<p>In the last lesson, we discussed <a href="https://pipwerks.com/packaging-a-scorm-course/" rel="noreferrer">how to package SCORM courses</a>. In this lesson, we&apos;ll discuss how to make your life easier with SCORM wrappers.</p><p>A SCORM wrapper is JavaScript library that serves as an abstraction layer between the SCORM API and your course. If you&#x2019;ve</p>]]></description><link>https://pipwerks.com/using-a-scorm-wrapper-to-simplify-the-workflow/</link><guid isPermaLink="false">680dcba37f494f0001d5f81c</guid><category><![CDATA[SCORM]]></category><category><![CDATA[SCORM wrapper]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Mon, 16 Sep 2024 03:00:45 GMT</pubDate><content:encoded><![CDATA[<p>In the last lesson, we discussed <a href="https://pipwerks.com/packaging-a-scorm-course/" rel="noreferrer">how to package SCORM courses</a>. In this lesson, we&apos;ll discuss how to make your life easier with SCORM wrappers.</p><p>A SCORM wrapper is JavaScript library that serves as an abstraction layer between the SCORM API and your course. If you&#x2019;ve ever used jQuery, you can think of a SCORM wrapper&#x2019;s relationship to the SCORM API as being very similar to jQuery&#x2019;s relationship with a web browser&#x2019;s DOM &#x2014; the library&#x2019;s goal is to help you write shorter, more concise JavaScript while helping you avoid some of the more painful parts of the underlying system. (The jQuery motto is &#x201C;Write less, do more.&#x201D;)</p><p>There are many SCORM wrappers available to you; they are not all created equal, and often handle tasks differently. For the purposes of this SCORM for Developers series, I will use the <a href="https://github.com/pipwerks/scorm-api-wrapper?ref=pipwerks.com">pipwerks SCORM wrapper</a>, which I created back in 2008. While not perfect, it&#x2019;s very reliable &#x2014; it has been used in thousands of courses around the world over the fifteen years &#x2014; and is open-source.</p><h2 id="the-adl-scorm-wrapper">The ADL SCORM Wrapper</h2><p>ADL provided a sample SCORM 1.2 wrapper when they released SCORM 1.2 in October 2001. Their wrapper became very popular and has been very widely used. I&#x2019;m sure you can still find a number of courses using it. ADL&#x2019;s wrapper was never intended to be THE wrapper &#x2014; by their own admission, it was created as an example of what was possible. They expected others to extend and improve it. The comments in their wrapper mention &#x201C;This is just one possible example for implementing the API guidelines for runtime communication between an LMS and executable content components. There are several other possible implementations.&#x201D;</p><p>When I used the ADL wrapper in my courses, I found myself frustrated by writing the same chunks of SCORM code over and over, such as error-checking and exit handling. I figured if I could stick all of these items into a new wrapper, I would save myself a lot of time and typing. It would also allow me to clean up the JavaScript global space by eliminating all of the global variables used in the ADL wrapper.</p><p>If you don&#x2019;t quite understand what all of that means, it&#x2019;s okay, we&#x2019;ll touch on these topics as we build our next sample course.</p><h2 id="the-pipwerks-scorm-wrapper">The pipwerks SCORM wrapper</h2><p>The pipwerks SCORM wrapper was designed to make course development easier, so you can focus more on your course and less on the nit-picky SCORM stuff. The wrapper will help you with a number of tasks:</p><ul><li>Find the SCORM API and initialize the SCORM session</li><li>Keep the global namespace clean</li><li>Keep your code concise (shorter syntax, easier to understand naming conventions, helper functions, automation)</li><li>Check for errors (e.g., was a SCORM call successful, or did the LMS return an error?)</li><li>Keep your course abstracted (easily switch between SCORM 1.2 and SCORM 2004 if needed)</li></ul><h3 id="find-the-scorm-api-and-initialize-the-scorm-session">Find the SCORM API and initialize the SCORM session</h3><p>If you&#x2019;re working without a wrapper and writing your SCORM code directly, as we did in our example course at the beginning of this SCORM for Developers series, you would need to write quite a chunk of code. For starters, you&#x2019;d need a function that locates the SCORM API. It can be tricky to find the SCORM API in some LMSs: Is it in the course window? The parent window? A frameset? A popup window? (Believe it or not, sometimes the answer is &#x201C;all of the above.&#x201D;)</p><p>Then once the API is found, you&#x2019;d need to make it available to your course, then initialize the SCORM session using API.LMSInitialize. You&#x2019;d need to verify whether or not the initialization was successful, and in most cases, will want to immediately set the course completion status to &#x201C;incomplete&#x201D;, then invoke LMSCommit.</p><p>I got tired of re-writing all of that code over and over, so I incorporated it into the pipwerks wrapper&#x2019;s init routine. Now you can just write:</p><pre><code class="language-javascript">pipwerks.SCORM.init();</code></pre><h3 id="keep-the-global-namespace-clean">Keep the global namespace clean</h3><p>Unlike other SCORM wrappers, all of the SCORM code in the pipwerks wrapper, including utility functions, getters/setters, debugging, etc., is contained under one global variable: <code>pipwerks</code>. This keeps the global namespace clean, which is important for preventing errors due to naming conflicts, and is a generally accepted best practice.</p><p>I will typically add a second global variable named <code>scorm</code> to my courses for convenience; it&#x2019;s much easier to write <code>scorm.doSomething()</code> than <code>pipwerks.SCORM.doSomething()</code>.</p><pre><code class="language-javascript">var scorm = pipwerks.SCORM;
//shorthand
scorm.init();
scorm.set(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);
scorm.save();</code></pre><p>This means at most I have only added two global variables to the page, which is a nice, clean way of working.</p><h3 id="keep-your-code-concise">Keep your code concise</h3><p>You&#x2019;ve already seen how the pipwerks wrapper can save you some typing, but it is also designed to make your course code more readable. Instead of typing</p><pre><code class="language-javascript">API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);
API.LMSCommit(&quot;&quot;);</code></pre><p>You can just type</p><pre><code class="language-javascript">scorm.set(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);
scorm.save();</code></pre><p>As you can see, I use slightly different syntax; I shortened <code>LMSSetValue</code> to <code>set</code>, and changed <code>LMSCommit</code> to <code>save</code>, without requiring the nonsensical empty quotes required by <code>LMSCommit(&quot;&quot;)</code>. I find these terms easier to remember, easier to type, and easier to scan when I&#x2019;m writing my course code.</p><p>Also, don&#x2019;t forget the pipwerks wrapper has additional automation running under the hood; in the example above, the <code>scorm.set</code> function first checks to ensure the connection is active, then attempts to set the value. If the connection is not active, or if the call is not successful, the pipwerks wrapper will ping the LMS to get the error code and display the error in your JavaScript console.</p><p>Although the examples above look nearly identical, the second example is giving you much more functionality than the first example.</p><h3 id="check-for-errors">Check for errors</h3><p>As mentioned in the SCORM API lesson, most SCORM functions return a value indicating whether or not they were successful. These values are booleans, but presented as the strings &#x201C;true&#x201D; and &#x201C;false&#x201D;. The pipwerks wrapper automatically converts them to authentic booleans, making them much easier to work with.</p><p>To illustrate, we can modify the example given above to verify whether the SCORM calls were successful:</p><pre><code class="language-javascript">var scorm = pipwerks.SCORM;
var wasSetSuccessful = scorm.set(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);
var wasSaveSuccessful = scorm.save();
if (wasSetSuccessful &amp;&amp; wasSaveSuccessful) {
  alert(&quot;The LMS likes you!&quot;);
} else {
  alert(&quot;The LMS is not pleased.&quot;);
}</code></pre><p>The wrapper also provides some preventative measures, such as automatically ensuring the SCORM session is still active before trying to get or set data.</p><p>When a SCORM API call is not successful, the LMS provides an error message providing more information. Retrieving this error code requires additional SCORM calls: <code>LMSGetLastError</code> and <code>LMSGetErrorString</code>. The pipwerks wrapper handles these via <code>debug.getCode</code> and <code>debug.getInfo</code>.</p><pre><code class="language-javascript">var scorm = pipwerks.SCORM;
var wasSetSuccessful = scorm.set(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);
var wasSaveSuccessful = scorm.save();
if (wasSetSuccessful &amp;&amp; wasSaveSuccessful) {
  alert(&quot;The LMS likes you!&quot;);
} else {
  var message = scorm.debug.getInfo(scorm.debug.getCode());
  alert(&quot;The LMS is not pleased. It says &quot; + message);
}</code></pre><p>For convenience, all SCORM error messages are automatically displayed in the browser&#x2019;s JavaScript console &#x2014; no need to write a bazillion <code>alert()</code> or <code>console.log()</code> statements! If you prefer not to display error messages in the JavaScript console, you can easily turn them off with one line of JavaScript:</p><pre><code class="language-javascript">pipwerks.debug.isActive = false;</code></pre><h3 id="keep-your-course-abstracted">Keep your course abstracted</h3><p>The pipwerks SCORM wrapper has one other nifty feature missing from most wrappers: it supports both SCORM 1.2 and 2004. SCORM 2004 uses slightly different syntax than SCORM 1.2, such as <code>API.Initialize</code> instead of <code>API.LMSInitialize</code> and <code>API.Terminate</code> instead of <code>API.LMSFinish</code>. The pipwerks wrapper will look to see which version of SCORM is being used by the course, and will select the appropriate syntax.</p><p>If you ever decide to convert a course from SCORM 1.2 to 2004 (or vice-versa) you would just need to change the CMI fields in your gets/sets, such as &#x201C;cmi.learner_name&#x201D; instead of &#x201C;cmi.core.student_name&#x201D;.</p><p>Since this SCORM for Developers series is focusing on SCORM 1.2, the ability to jump to SCORM 2004 will not have much impact here, but it&#x2019;s worth mentioning in case you ever decide to give SCORM 2004 a whirl.</p>]]></content:encoded></item><item><title><![CDATA[Packaging a SCORM Course]]></title><description><![CDATA[<p>In the last lesson we briefly covered <a href="https://pipwerks.com/testing-scorm-courses/" rel="noreferrer">how to test SCORM courses</a>. If you&#x2019;d like to test a course in an LMS, you&#x2019;ll need to package the course, so let&#x2019;s take a moment to discuss packaging.</p><h2 id="packaging-scorm-12-courses">Packaging SCORM 1.2 Courses</h2><p>A SCORM package</p>]]></description><link>https://pipwerks.com/packaging-a-scorm-course/</link><guid isPermaLink="false">680dcba37f494f0001d5f81d</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Mon, 16 Sep 2024 02:01:31 GMT</pubDate><content:encoded><![CDATA[<p>In the last lesson we briefly covered <a href="https://pipwerks.com/testing-scorm-courses/" rel="noreferrer">how to test SCORM courses</a>. If you&#x2019;d like to test a course in an LMS, you&#x2019;ll need to package the course, so let&#x2019;s take a moment to discuss packaging.</p><h2 id="packaging-scorm-12-courses">Packaging SCORM 1.2 Courses</h2><p>A SCORM package normally consists of:</p><ul><li>A SCORM manifest (the imsmanifest.xml file) at the root of the package</li><li>Supporting XML schema files</li><li>Your course content</li></ul><p>By spec, a SCORM course needs to be packaged in a Package Interchange Format (PIF) file, which is really just a plain old ZIP archive but with a PIF file extension instead of ZIP. In reality, most learning management systems accept ZIP files, so don&#x2019;t worry about using the PIF extension.</p><h2 id="manifest">Manifest</h2><p>First, a little background on SCORM manifests. A SCORM manifest is just an XML file containing a specific set of nodes. These nodes provide details about your course, including title, version number, and location of course files (hence the name &#x2018;manifest&#x2019;, like a ship&#x2019;s manifest). When the course package is imported into the LMS, the LMS analyzes the manifest, grabbing the course&#x2019;s metadata and determining which HTML file should be used as the start page for the course.</p><p>As I explained earlier, the authors of SCORM had a grand vision of what SCORM could be, but a number of concepts in that vision never really panned out. As with the CMI data model, the SCORM manifest system was designed to support many intriguing options, including nested SCOs (a SCORM course within a SCORM course), standardizing how courses navigate between course pages, and roll-up rules that impact how a course is considered completed. And, like the CMI data model, most LMSs don&#x2019;t uniformly support the full feature set.</p><p>SCORM documentation includes an entire book devoted to the manifest (&#x201C;SCORM Content Aggregation Model&#x201D;), which illustrates just how convoluted a manifest can become.</p><p>SCORM 1.2 listed many features as optional and left it up to the vendor to decide whether or not to implement the feature. As you can imagine, many vendors opted not to implement features, which caused headaches for developers who wanted to use the features.</p><p>With the release of SCORM 2004, the ADL set out to correct this issue by requiring all features to be implemented by LMS vendors. Coincidentally, SCORM 2004 also introduced many new sequencing and navigation options, making a SCORM 2004 implementation much more complex and expensive to implement than SCORM 1.2. As a result, some vendors complied with the new ADL requirements, but many did not, due to the complexity and cost of implementation.</p><p>This caused a chicken-or-egg scenario: E-learning authoring tools did not utilize any manifest features beyond the bare minimum because most LMSs did not support them. LMS vendors were quick to point out they didn&#x2019;t need to support &#x2018;advanced&#x2019; features because most e-learning authoring tools weren&#x2019;t utilizing them.</p><p>I waited for years to see if LMS vendors would begin supporting the full feature set, as required by SCORM 2004&#x2019;s stricter conformance rules. In the meantime, I followed the same path as the commercial vendors &#x2014; for the sake of being &#x2018;bulletproof&#x2019;, I kept it simple, and it served me well. Two decades later, it&#x2019;s safe to say most LMS vendors will never implement the full feature set, and I wholeheartedly recommend keeping your manifest as barebones as possible.</p><p>Also note: Using the LMS&#x2019;s built-in course navigation (next, previous, table of contents, etc.) means your course UI (and therefore your learners&#x2019; experience) will be inconsistent from LMS to LMS. Using your own navigation system ensures your course will look and behave as expected, regardless of which LMS it has been loaded in.</p><h2 id="manifest-template">Manifest Template</h2><p>To help streamline my development workflow, I created a <a href="https://github.com/pipwerks/SCORM-Manifests?ref=pipwerks.com">SCORM packaging template</a> that covers the bare minimum required by SCORM. This template includes the manifest and the supporting XML schema files. To use this template for a new course, just copy the manifest and the SCORM-schemas folder to the root of your new course project, then edit a few details in the imsmanifest.xml file: course title, course version, course ID, and organization name.</p><p>You don&#x2019;t need to understand everything going on here; all you need to do is change the values I&#x2019;ve highlighted below to match your course.</p><pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; standalone=&quot;no&quot; ?&gt;
&lt;manifest identifier=&quot;CourseIDHere&quot; version=&quot;1&quot;
         xmlns=&quot;http://www.imsproject.org/xsd/imscp_rootv1p1p2&quot;
         xmlns:adlcp=&quot;http://www.adlnet.org/xsd/adlcp_rootv1p2&quot;
         xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
         xsi:schemaLocation=&quot;http://www.imsproject.org/xsd/imscp_rootv1p1p2 SCORM-schemas/imscp_rootv1p1p2.xsd
                             http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 SCORM-schemas/imsmd_rootv1p2p1.xsd
                             http://www.adlnet.org/xsd/adlcp_rootv1p2 SCORM-schemas/adlcp_rootv1p2.xsd&quot;&gt;
  &lt;metadata&gt;
    &lt;schema&gt;ADL SCORM&lt;/schema&gt;
    &lt;schemaversion&gt;1.2&lt;/schemaversion&gt;
  &lt;/metadata&gt;
  &lt;organizations default=&quot;course-code-here&quot;&gt;
    &lt;organization identifier=&quot;course-code-here&quot;&gt;
      &lt;title&gt;Course Title here&lt;/title&gt;
      &lt;item identifier=&quot;item_1&quot; identifierref=&quot;resource_1&quot;&gt;
        &lt;title&gt;Course Title here&lt;/title&gt;
      &lt;/item&gt;
    &lt;/organization&gt;
  &lt;/organizations&gt;
  &lt;resources&gt;
    &lt;resource identifier=&quot;resource_1&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;sco&quot; href=&quot;content/index.html&quot;&gt;
      &lt;file href=&quot;content/index.html&quot; /&gt;
    &lt;/resource&gt;
  &lt;/resources&gt;
&lt;/manifest&gt;</code></pre><ul><li><strong>Manifest identifier</strong>: This is your course&#x2019;s ID. It needs to begin with a letter, and cannot contain spaces or special characters (hyphens and underscores are allowed).</li><li><strong>Manifest version</strong>: This is the version number for your course. In my experience, it doesn&#x2019;t really matter what number you provide here, it&#x2019;s largely for your own benefit.</li><li><strong>Organizations default</strong>: Many LMSs will use whatever you put here as the course code displayed in the LMS. Like the manifest identifier, it needs to begin with a letter, and cannot contain spaces or special characters (hyphens and underscores are allowed).</li><li><strong>Organizations identifier</strong>: I usually just mirror the value I provide under organizations default.</li><li><strong>Organization title</strong>: The title of your course.</li><li><strong>Item title</strong>: The title of your course (again).</li></ul><p>And that&#x2019;s it! Really simple, nothing to be afraid of.</p><p>You may have noticed there are two references to index.html. These are pointers to your course&#x2019;s launch file &#x2014; the file the LMS will open when the learner launches your course. If your course&#x2019;s root HTML file ever has a filename other than index.html, you&#x2019;d need to change these values to match. My courses are always set to use index.html, so I never need to edit these values.</p><p>You may also have noticed a few fields are redundant, such as two <code>&lt;title&gt;</code> elements. Why the redundancy? Without getting into the weeds, the SCORM manifest is designed to handle a variety of complex course configurations, including courses comprised of multiple SCORM packages (commonly known as multi-SCO courses). Since we are not using any of these advanced features, we end up with a streamlined manifest that has a couple of redundant fields.</p><h2 id="a-word-about-the-resources-node">A word about the resources node</h2><p>By spec, every course asset contained in the ZIP file is supposed to be listed within the <code>&lt;resources&gt;</code> node as either a &#x201C;sco&#x201D; (the course&#x2019;s primary launch file) or &#x201C;asset&#x201D; (any supporting file, including images, scripts, HTML, etc.). For example:</p><pre><code class="language-xml">&lt;resources&gt;
    &lt;resource identifier=&quot;resource_1&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;sco&quot; href=&quot;index.html&quot;&gt;
        &lt;file href=&quot;index.html&quot; /&gt;
    &lt;/resource&gt;
    &lt;resource identifier=&quot;resource_2&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;asset&quot; href=&quot;page2.html&quot;&gt;
        &lt;file href=&quot;page2.html&quot; /&gt;
    &lt;/resource&gt;
    &lt;resource identifier=&quot;resource_3&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;asset&quot; href=&quot;myscripts.js&quot;&gt;
        &lt;file href=&quot;myscripts.js&quot; /&gt;
    &lt;/resource&gt;
    &lt;resource identifier=&quot;resource_4&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;asset&quot; href=&quot;mystyles.css&quot;&gt;
        &lt;file href=&quot;mystyles.css&quot; /&gt;
    &lt;/resource&gt;
    &lt;resource identifier=&quot;resource_5&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;asset&quot; href=&quot;mypicture.jpg&quot;&gt;
        &lt;file href=&quot;mypicture.jpg&quot; /&gt;
    &lt;/resource&gt;
&lt;/resources&gt;</code></pre><p>As you can imagine, this is a major pain to deal with. Thankfully, most LMSs do not analyze the manifest&#x2019;s nodes for anything apart from the adlcp:scormtype=&#x201D;sco&#x201D; file, which is used to launch the course. It&#x2019;s usually safe to ignore the resource nodes.</p><p>However, some LMSs do use the <code>&lt;resource&gt;</code> nodes as a true manifest of goods (a list of everything the LMS will be needing to support the course) and will delete any files in your ZIP that are not listed in the manifest. <strong>This is rare</strong> but possible.</p><p>If you encounter this issue, there are a few open-source SCORM packaging tools floating around that you might find useful. You can also write a script that analyzes your course folder and writes up a list of files for you. I&#x2019;ve posted an <a href="https://gist.github.com/pipwerks/9179518?ref=pipwerks.com">AppleScript demonstrating this technique on GitHub</a>.</p><h2 id="zipping-it-up">Zipping it up</h2><p>As explained earlier, a SCORM package is a glorified ZIP archive. Once your content and manifest are settled, you&#x2019;ll need to zip up the files.</p><p>It&#x2019;s very important to leave the imsmanifest.xml file at the root of the ZIP file. If you don&#x2019;t, the LMS will throw an error when you import your package. If you follow my technique for organizing files, your end result will look like this:</p><figure class="kg-card kg-image-card"><img src="https://pipwerks.com/content/images/2024/09/SCORM-ZIP-contents-300x159.png" class="kg-image" alt="SCORM package contents" loading="lazy"></figure><p>On a Mac, create the ZIP by selecting the files and right-clicking. Select &#x201C;Compress&#x201D;.</p><figure class="kg-card kg-image-card"><img src="https://pipwerks.com/content/images/2024/09/zipping-correct.png" class="kg-image" alt="Correct way to create a course ZIP file on a Mac" loading="lazy" width="1002" height="1116" srcset="https://pipwerks.com/content/images/size/w600/2024/09/zipping-correct.png 600w, https://pipwerks.com/content/images/size/w1000/2024/09/zipping-correct.png 1000w, https://pipwerks.com/content/images/2024/09/zipping-correct.png 1002w" sizes="(min-width: 720px) 720px"></figure><p>After zipping the folder, <strong>unzip</strong> it and ensure the resulting folder looks like the first image above.</p><p>Don&#x2019;t try to compress the parent folder, this will lead to incorrectly nested files. The LMS will not be able to find the manifest and will give you an error.</p><figure class="kg-card kg-image-card"><img src="https://pipwerks.com/content/images/2024/09/zipping-incorrect.png" class="kg-image" alt="Incorrect way to create a course ZIP file on a Mac" loading="lazy" width="914" height="1106" srcset="https://pipwerks.com/content/images/size/w600/2024/09/zipping-incorrect.png 600w, https://pipwerks.com/content/images/2024/09/zipping-incorrect.png 914w" sizes="(min-width: 720px) 720px"></figure>]]></content:encoded></item><item><title><![CDATA[Testing SCORM Courses]]></title><description><![CDATA[<p>Before we start building functional SCORM courses, let&#x2019;s take a moment to discuss how you can test your SCORM courses.</p><p>One of the biggest obstacles for venturing into hand-built courseware is probably the lack of a built-in preview feature. This also impacts testing. When you use a commercial</p>]]></description><link>https://pipwerks.com/testing-scorm-courses/</link><guid isPermaLink="false">680dcba37f494f0001d5f81e</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Sun, 15 Sep 2024 12:32:40 GMT</pubDate><content:encoded><![CDATA[<p>Before we start building functional SCORM courses, let&#x2019;s take a moment to discuss how you can test your SCORM courses.</p><p>One of the biggest obstacles for venturing into hand-built courseware is probably the lack of a built-in preview feature. This also impacts testing. When you use a commercial e-learning development tool, it typically includes a preview feature, enabling you to launch and interact with your course without loading it into an LMS. You can test things out and make smalls edits quickly. If you see an issue in your course, you close the preview window, make your edit, then relaunch the preview window, sometimes in a matter of seconds. The ability to quickly jump between the source material and the preview mode is a very alluring feature.</p><p>How can we do that if we&#x2019;re building courses by hand? Sure, you can launch the course in your local web browser, but the browser won&#x2019;t be able to provide the SCORM API, which means you can&#x2019;t test the SCORM functionality. When you&#x2019;re ready to test your SCORM tracking code, you&#x2019;ll need to publish it to an LMS, which means setting up your SCORM manifest, packaging the course as a ZIP, uploading the ZIP to an LMS, configuring the course settings in the LMS, then launching the course in the LMS. If you discover a typo, or just want to make a small tweak to your code, you&#x2019;ll (usually) need to go through the same steps again. It becomes very tedious very quickly.</p><p>This is why I break my testing into three phases:</p><ol><li>Local testing using a web browser</li><li>Local testing using a faux LMS</li><li>Remote testing on a real LMS</li></ol><h2 id="local-testing-using-a-web-browser">Local testing using a web browser</h2><p>In this first stage, I&#x2019;m not worried about testing the tracking code. I&#x2019;m more concerned about the basics: Ensuring my course navigation works, and that the user experience is as good as it can be. This includes ensuring the controls are accessible, and that the pages are responsive (they are flexible and will resize to fit different viewports). The first draft of a course might not even have any SCORM code in it, and should work in a web browser just like any other web site: you can open it, navigate, interact with it, etc.</p><p>This first stage is the best time to confirm the content, look, feel, and general functionality of the course before getting caught up in the LMS side of things. Ensure your content is accurate, that you don&#x2019;t have any typos, that you have all of your final artwork in place, etc.</p><p>It&#x2019;s critical that you test your course in different web browsers at this early stage of development. There are few things worse than developing a course which functions beautifully in one browser only to find it horribly broken in others. (Though, thankfully, this is less of an issue with modern browsers than it used to be.)</p><p>I generally do most of my initial development and testing in a single browser (typically Mozilla Firefox, simply because it&#x2019;s my default browser), and jump to other browsers whenever I add new layouts or functionality to the course, such as a new interaction. Always ensure the new items look and behave as expected in all major browsers.</p><p>Don&#x2019;t get hung up on making the courses look identical and pixel-perfect in every browser; each browser brings its own unique style and quirks to webpages, especially when using form elements such as drop-down menus. The focus should be on the content and functionality. Ask yourself if the user is receiving the same experience across all browsers, even if a few items (scrollbars, form fields, etc.) look different.</p><p>This is also a good time to ensure the courseware is keyboard accessible. In my opinion, being able to navigate the entire course using a keyboard (no mouse or trackpad) is a requirement, and should not be an afterthought. Many screen readers and assistive technologies use keyboard mappings to help their users navigate the course. Of course, accessibility runs much deeper than keyboard access, but this is an excellent way to train yourself to remember accessibility while you develop your courseware.</p><p>Finally, if you have a subject-matter expert, instructional designer, or client who needs to review and sign off on the course, do it at this stage if possible. It will save you from hassles down the road, since you can quickly update content without jumping through LMS hoops.</p><h3 id="notes">Notes</h3><ul><li>Tools like <a href="https://www.browserstack.com/?ref=pipwerks.com">BrowserStack</a> and <a href="https://www.lambdatest.com/cross-browser-testing?ref=pipwerks.com">LambdaTest</a> are an easy way to test your course across browsers and platforms.</li><li>See sites such as <a href="https://www.w3counter.com/trends?ref=pipwerks.com">https://www.w3counter.com/trends</a> for the latest trends in browser market share.</li><li>I like to test courses across the following browsers and platforms:Google Chrome (macOS and Windows)Firefox (macOS and Windows)Microsoft Edge (Windows only)Safari (macOS only)
</li><li>I sometimes test in Opera, but it has significantly smaller marketshare, and uses the same Blink rendering engine as Chrome. I don&#x2019;t consider Opera a hard requirement. (This may not be the case for some European course developers, as Opera is much more widely used in Europe than the United States.)</li><li>Microsoft Edge also uses the same rendering engine as Chrome, so if the course works in Google Chrome, it typically also works in Opera and Edge.</li><li>If you plan to support mobile devices, you should test Safari for iOS as well as Chrome for Android devices.</li></ul><h2 id="local-testing-using-a-faux-lms">Local testing using a faux LMS</h2><p>In the second phase of testing, I test locally using a home-built faux-LMS. The faux LMS is basically a simple one- or two-page website running on my local machine. It reads files from my local filesystem, enabling it to display a list of local courses, which launch in a popup window when clicked.</p><p>The system uses a stripped-down SCORM API and saves the SCORM data to the web browser&#x2019;s LocalStorage. All SCORM calls are properly recorded, including bookmarks. I can exit a course then resume it, just as you would on a production LMS.</p><p>This sounds very complicated, but in reality the experience is almost the same as simply viewing the course in a web browser. The only differences are that the course can be launched in a constrained pop-up window (mimicking an LMS experience) and the course will have SCORM tracking support.</p><p>Using a faux LMS is a real time-saver compared to using a true LMS, as it allows me to test files locally without uploading the entire course to an LMS, doesn&#x2019;t require creating a ZIP package, and doesn&#x2019;t require any fiddling with course settings or versioning in the LMS user interface. If I were using a real LMS, a task such as fixing a simple typo would require creating a new ZIP, uploading the ZIP to the LMS, then dealing with the LMS&#x2019;s versioning system. And many times, when you launch the edited course you will run into caching issues, where the old version of the file remains cached on the LMS for some reason. In the faux LMS environment, if I spot a typo on a course page, I can quickly edit that file locally without having to repackage and re-upload to the LMS.</p><p>Unfortunately, I&#x2019;m not ready to share my faux-LMS systems yet (sorry for being a tease!), but if you&#x2019;d like to try something similar, look into open-source systems like <a href="https://github.com/jcputney/scorm-again?ref=pipwerks.com">Jonathan Putney&#x2019;s SCORM Again</a> or <a href="https://github.com/frumbert/scorm_debug?ref=pipwerks.com">Tim St Clair&#x2019;s SCORM Debug</a>.</p><h2 id="remote-testing-on-a-real-lms">Remote testing on a real LMS</h2><p>For the third phase of testing, I upload the course to a staging LMS to confirm it works as expected. This staging system is typically a clone of a production LMS, specifically provided to developers for testing courses.</p><p>If you don&#x2019;t have a staging LMS, you may want to consider installing an installing an open-source LMS locally. It takes some effort, but will give you decent results. Some LMSs to consider are <a href="https://moodle.org/?ref=pipwerks.com">Moodle</a>, <a href="https://chamilo.org/en/?ref=pipwerks.com">Chamilo</a>, <a href="https://www.formalms.org/?ref=pipwerks.com">Forma</a>, and <a href="https://www.ilias.de/en/?ref=pipwerks.com">Ilias</a>. Alternatively, you can use a third-party service such as <a href="https://rusticisoftware.com/products/scorm-cloud/?ref=pipwerks.com">Rustici Software&#x2019;s SCORM Cloud</a>. To be honest, SCORM Cloud is one of the best ways to test a course, as it provides very useful SCORM logs and error messages, which can help pinpoint any SCORM-related issue you may be encountering. SCORM Cloud provides free accounts for testing smaller courses, but requires payment for larger courses. It also requires uploading your files to their servers, which may present security and/or confidentiality issues for some clients.</p><p>Regardless, testing on a staging system is crucial for verifying the functionality of the course in a production environment before releasing it to your learners. Always check the course in a true LMS environment before assigning the course to learners.</p><p>Since LMS platforms vary, they sometimes introduce cross-platform issues. Even though you may have tested your course across browsers and platforms locally, you should do it again when your course has been loaded in the LMS. It&#x2019;s always better to catch these issues yourself than having your boss/customer or end users (learners) finding them for you.</p><p>In short, test, test, and test again.</p>]]></content:encoded></item><item><title><![CDATA[Fleshing Out the SCORM Example]]></title><description><![CDATA[<p>In the last lesson in this SCORM for Developers series, we dipped our toes in the water and created the most barebones SCORM course possible. In this lesson, we&#x2019;ll wade a little deeper, adding sophistication to the course via a smattering of JavaScript and HTML. We&#x2019;ll</p>]]></description><link>https://pipwerks.com/fleshing-out-the-scorm-example/</link><guid isPermaLink="false">680dcba37f494f0001d5f823</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Thu, 29 Aug 2024 12:54:19 GMT</pubDate><content:encoded><![CDATA[<p>In the last lesson in this SCORM for Developers series, we dipped our toes in the water and created the most barebones SCORM course possible. In this lesson, we&#x2019;ll wade a little deeper, adding sophistication to the course via a smattering of JavaScript and HTML. We&#x2019;ll use cmi.core.lesson_status, implement some error-checking, personalize the content, and even require an interaction before granting a course completion.</p><p>Don&#x2019;t be alarmed by the fast pace of this lesson &#x2014; this is just a quick primer covering the basic concepts and architecture. Remember, <strong>this is a non-functioning example</strong>. We will walk through fully functional examples line-by-line later in the series.</p><h2 id="checking-lesson-status">Checking Lesson Status</h2><p>The previous lesson included the CMI field cmi.core.lesson_status. This field is responsible for maintaining the status of the course. It&#x2019;s a read/write field, which means you can retrieve its value, and you can also change its value.</p><p>cmi.core.lesson_status is not a free-form text field, and will only accept specific values (referred to as tokens in the SCORM documentation). The allowed values for cmi.core.lesson_status are:</p><ul><li>&#x201C;not attempted&#x201D; &#x2014; the default status.</li><li>&#x201C;incomplete&#x201D; &#x2014; the course has been started but the learner has not finished it.</li><li>&#x201C;completed&#x201D; &#x2014; indicates course is completed but does not indicate whether the appropriate passing score was reached.</li><li>&#x201C;passed&#x201D; &#x2014; indicates course is both completed and passed.</li><li>&#x201C;failed&#x201D; &#x2014; the opposite of &#x201C;passed&#x201D;, the course is likely completed but the learner hasn&#x2019;t met the criteria for passing (the criteria is defined by you, within your own course logic, not by SCORM).</li><li>&#x201C;browsed&#x201D; &#x2014; the course has been launched in &#x201C;browse mode&#x201D;.<em>I don&#x2019;t recommend using browse mode, as I find it often creates confusion regarding completion status and score. There are other techniques available for enabling a learner to relaunch a course after completion without altering the score, which will be covered later in this series.</em>
</li></ul><p>The statuses you&#x2019;ll use most often are &#x201C;incomplete&#x201D; coupled with either &#x201C;completed&#x201D; or &#x201C;passed&#x201D;, depending on your preference and needs. You might use &#x201C;failed&#x201D;, but who are we kidding, most courses are built to never let the learner fail! (But by all means, use it if it fits your scenario.)</p><p>It&#x2019;s a best practice to check the course status immediately upon launching your course. This will enable you to properly handle several scenarios:</p><ol><li>If this is the first time the learner has launched the course, the value will typically be &#x201C;not attempted&#x201D;. In most cases, you will want to immediately change the value to &#x201C;incomplete&#x201D;, which officially denotes (implies) the learner has accessed the course at least once.</li><li>If the course status is set to &#x201C;incomplete&#x201D;, in most cases this is where you would insert some code to check for a bookmark, so you can redirect the learner to where they left off when they exited the course.</li><li>If the course status is set to &#x201C;completed&#x201D; or &#x201C;passed&#x201D;, you can assume the learner is back to review content. In this scenario, you&#x2019;d disable tracking in your course, freeing the learner to navigate and interact with the course without fear of altering the course status (and score, if applicable) achieved when they initially completed the course.</li></ol><p>For the purposes of our little example, let&#x2019;s keep it simple and check to see if the course has already been set to &#x201C;completed&#x201D; or &#x201C;passed&#x201D; before we try to change the value of cmi.core.lesson_status. We can do this by using LMSGetValue, which, as you may have guessed, retrieves the value of a CMI field from the LMS.</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;script&gt;

var API = window.API;

//begin the SCORM session
API.LMSInitialize(&quot;&quot;);

//Get the lesson status from LMS
var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

//Only set the status to &quot;completed&quot; if it is not already considered completed
if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

   //Explicitly set course status to &#x201C;completed&#x201D;
   API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

   //Don&#x2019;t forget to commit!
   API.LMSCommit(&quot;&quot;);

}

//end the SCORM session
API.LMSFinish(&quot;&quot;); 

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>In this example, the conditional statement will only set the course to &#x201C;completed&#x201D; if it hasn&#x2019;t already been set to &#x201C;completed&#x201D; or &#x201C;passed&#x201D;. If the course has been completed, don&#x2019;t try to set the course to &#x201C;completed&#x201D; again, just leave it be.</p><h2 id="error-checking">Error Checking</h2><p>Some will say it&#x2019;s a good practice to include error-checking. I say it&#x2019;s imperative. Case in point: What happens when LMSInitialize isn&#x2019;t successful? (It&#x2019;s rare, but it happens.)</p><p>If LMSInitialize is not successful, and you don&#x2019;t account for it in your course logic, the learner will continue to interact with the course, thinking the course is functioning normally, but the LMS will not be saving any information about the course session. In our little example, the learner would only miss out on the completion status, but in the real world, a learner could navigate through dozens of course pages and not receive credit. You&#x2019;d spend the next week answering angry support calls!</p><p>Thankfully, LMSInitialize returns a value indicating whether initialization was successful.</p><p>As seen in the code example below, we can perform a check to ensure LMSInitialize is successful before moving on to our other course functions. If LMSInitialize is not successful, we can alert the user.</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;script&gt;

var API = window.API;

//begin the SCORM session
var isInitialized = (API.LMSInitialize(&quot;&quot;) === &quot;true&quot;); //create a boolean value

if(isInitialized){

   //Get the lesson status from LMS
   var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

   //Only set the status to &quot;completed&quot; if it is not already considered completed
   if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

      //Explicitly set course status to &#x201C;completed&#x201D;
      API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

      //Don&#x2019;t forget to commit!
      API.LMSCommit(&quot;&quot;);

   }

   //end the SCORM session
   API.LMSFinish(&quot;&quot;); 

} else {

   alert(&quot;Uh-oh, LMSInitialize failed!&quot;);

}

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>The course now validates the initialization, and alerts the learner if the initialization failed.</p><p>As you can see, error-checking doesn&#x2019;t have to be difficult, but it&#x2019;s very important, both for reliability of your course and for user satisfaction. Many of the older SCORM tutorials on the interwebs don&#x2019;t utilize error-checking, which I feel is a big mistake. We will continue to implement error-checking strategies throughout the examples in this series.</p><h3 id="notes">Notes</h3><ul><li>In yet another quirk of the SCORM API, LMSInitialize returns a &#x2018;string boolean.&#x2019; Instead of an authentic boolean (true or false), we get the stringified versions (&#x201C;true&#x201D; and &#x201C;false&#x201D;). We need to account for these in our script.</li><li>Some LMSs don&#x2019;t return &#x201C;true&#x201D; or &#x201C;false&#x201D; as specified by the SCORM spec. This is one instance where SCORM wrappers come in handy. SCORM wrappers will be covered a bit later.</li></ul><h2 id="personalization">Personalization</h2><p>Another nice touch we can add is personalization. The CMI field cmi.core.student_name provides the learner&#x2019;s name, which we can dynamically insert into the course. Building on our example code, we can add a small bit of HTML markup, and a touch of JavaScript to populate it with the learner&#x2019;s name.</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;p&gt;Hello, &lt;span id=&quot;learner_name&quot;&gt;&lt;/span&gt;.&lt;/p&gt;

&lt;script&gt;

var API = window.API;

//Grab a reference to the learner_name span for future use
var name_span = document.querySelector(&quot;#learner_name&quot;);

//begin the SCORM session
var isInitialized = (API.LMSInitialize(&quot;&quot;) === &quot;true&quot;); //create a boolean value

if(isInitialized){

   //Get the learner&apos;s name from the LMS
   var learner_name = API.LMSGetValue(&quot;cmi.core.student_name&quot;);

   //Populate the learner_name span element with the learner&apos;s name
   name_span.innerHTML = learner_name;

   //Get the lesson status from LMS
   var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

   //Only set the status to &quot;completed&quot; if it is not already considered completed
   if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

      //Explicitly set course status to &#x201C;completed&#x201D;
      API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

      //Don&#x2019;t forget to commit!
      API.LMSCommit(&quot;&quot;);

   }

   //end the SCORM session
   API.LMSFinish(&quot;&quot;); 

} else {

   alert(&quot;Uh-oh, LMSInitialize failed!&quot;);

}

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>It may seem like a minor improvement, but in my opinion, including your learner&#x2019;s name on the screen helps improve confidence in the course. It also provides the opportunity to personalize any interactive sections you may build within the course. For example, you can create prompts for interactions such as &#x201C;OK, Jane, are you ready to give it a try?&#x201D;</p><h3 id="notes-1">Notes</h3><ul><li>When sending cmi.core.student_name, LMSs usually include both first name (given name) and last name (surname). There is no official standard for whether the name is returned in a specific sequence, such as &#x201C;Jane Doe&#x201D; or &#x201C;Doe, Jane&#x201D;, so be careful how you choose to use the cmi.core.student_name field.</li></ul><h2 id="require-user-interaction">Require User Interaction</h2><p>As currently constructed, the course doesn&#x2019;t require any interaction from the learner to earn their course completion. Let&#x2019;s require the learner to do something before granting the completion. For simplicity&#x2019;s sake, we&#x2019;ll just require a button click. To do this, we add a button to the HTML, then add a bit of JavaScript to handle what happens when the button is clicked.</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;p&gt;Hello, &lt;span id=&quot;learner_name&quot;&gt;&lt;/span&gt;.&lt;/p&gt;
&lt;button id=&quot;btn_complete&quot;&gt;Click me to complete this course&lt;/button&gt;

&lt;script&gt;

var API = window.API;

//Grab a reference to the learner_name span for future use
var name_span = document.querySelector(&quot;#learner_name&quot;);

//Grab a reference to the button for future use
var btn = document.querySelector(&quot;#btn_complete&quot;);

//Set up a click handler for the button
btn.addEventListener(&quot;click&quot;, function(e){
   //Ensure the browser only does what we tell it to when the button is clicked
   e.preventDefault();
});

//begin the SCORM session
var isInitialized = (API.LMSInitialize(&quot;&quot;) === &quot;true&quot;); //create a boolean value

if(isInitialized){

   //Get the learner&apos;s name from the LMS
   var learner_name = API.LMSGetValue(&quot;cmi.core.student_name&quot;);

   //Populate the learner_name span element with the learner&apos;s name
   name_span.innerHTML = learner_name;

   //Get the lesson status from LMS
   var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

   //Only set the status to &quot;completed&quot; if it is not already considered completed
   if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

      //Explicitly set course status to &#x201C;completed&#x201D;
      API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

      //Don&#x2019;t forget to commit!
      API.LMSCommit(&quot;&quot;);

   }

   //end the SCORM session
   API.LMSFinish(&quot;&quot;); 

} else {

   alert(&quot;Uh-oh, LMSInitialize failed!&quot;);

}

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>In this updated code, we&#x2019;ve added the button to click, and a click handler, but the click handler doesn&#x2019;t actually do anything yet.</p><p>This button&#x2019;s purpose is to set the course to &#x201C;completed&#x201D; when clicked, so let&#x2019;s cut and paste the relevant parts of the lesson_status check into the click handler:</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;button id=&quot;btn_complete&quot;&gt;Click me to complete this course&lt;/button&gt;

&lt;script&gt;

var API = window.API;
var btn = document.querySelector(&quot;#btn_complete&quot;);

btn.addEventListener(&quot;click&quot;, function(e){

   //Ensure the browser only does what we tell it to when the button is clicked
   e.preventDefault(); 

   if(isInitialized){

      //Get the lesson status from LMS
      var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

      //Only set the status to &quot;completed&quot; if it is not already considered completed
      if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

         //Explicitly set course status to &#x201C;completed&#x201D;
         API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

         //Don&#x2019;t forget to commit!
         API.LMSCommit(&quot;&quot;);

      }

      //end the SCORM session
      API.LMSFinish(&quot;&quot;); 

   } else {

      alert(&quot;Can&apos;t set the course to complete: it hasn&apos;t been initialized&quot;);

   }

});

//begin the SCORM session
var isInitialized = (API.LMSInitialize(&quot;&quot;) === &quot;true&quot;); //create a boolean value

if(isInitialized){

   //do nothing

} else {

   alert(&quot;Uh-oh, LMSInitialize failed!&quot;);

}

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>Note I&#x2019;ve wrapped the completion code in an if() statement, ensuring it will only be invoked if the course was properly initialized.</p><p>As currently constructed, when the course is launched, it will initialize the SCORM connection, but won&#x2019;t do anything else. When the learner clicks the button, the course will be set to &#x201C;completed&#x201D; and the SCORM connection will be terminated.</p><p>So far, so good. But let&#x2019;s improve the user experience by providing textual feedback to the learner when the button is clicked.</p><p>First we need to provide a place to put the text. In the <code>&lt;body&gt;</code>, we&#x2019;ll add a heading, and some instruction for the learner. Then we&#x2019;ll dynamically change the text when the button is clicked.</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;h1&gt;Course Title&lt;/h1&gt;

&lt;p&gt;Hello, &lt;span id=&quot;learner_name&quot;&gt;&lt;/span&gt;.&lt;/p&gt;

&lt;div id=&quot;message&quot;&gt;
Please click the button to indicate you have completed this course.
&lt;/div&gt;

&lt;button id=&quot;btn_complete&quot;&gt;Click me to complete this course&lt;/button&gt;

&lt;script&gt;

var API = window.API;

//Grab a reference to the learner_name span for future use
var name_span = document.querySelector(&quot;#learner_name&quot;);

//Grab a reference to the button for future use
var btn = document.querySelector(&quot;#btn_complete&quot;);

//Grab a reference to the &apos;message&apos; div for future use
var message_div = document.querySelector(&quot;#message&quot;);

//Set up a click handler for the button
btn.addEventListener(&quot;click&quot;, function(e){

   //Ensure the browser only does what we tell it to when the button is clicked
   e.preventDefault();

   if(isInitialized){

      //Get the lesson status from LMS
      var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

      //Only set the status to &quot;completed&quot; if it is not already considered completed
      if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

         //Explicitly set course status to &#x201C;completed&#x201D;
         API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

         message_div.innerHTML = &quot;Course set to completed. Congratulations!&quot;;

         //Don&#x2019;t forget to commit!
         API.LMSCommit(&quot;&quot;);

      } else {

         message_div.innerHTML = &quot;Course has already been completed.&quot;;

      }

      //end the SCORM session
      API.LMSFinish(&quot;&quot;); 

   } else {

      alert(&quot;Can&apos;t set the course to complete: it hasn&apos;t been initialized&quot;);

   }

});

//begin the SCORM session
var isInitialized = (API.LMSInitialize(&quot;&quot;) === &quot;true&quot;); //create a boolean value

if(isInitialized){

   //Get the learner&apos;s name from the LMS
   var learner_name = API.LMSGetValue(&quot;cmi.core.student_name&quot;);

   //Populate the learner_name span element with the learner&apos;s name
   name_span.innerHTML = learner_name;

} else {

   alert(&quot;Uh-oh, LMSInitialize failed!&quot;);

}

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>The value of <code>&lt;div id=&#x201D;message&#x201D;&gt;</code> changes based on the outcome of the button click. It sounds so simple, but is a very powerful way to improve the user experience.</p><p>With this change, the learner gets feedback as soon as the button is clicked. If the course is properly initialized, and the course has not already been completed, the learner is informed the course is now complete. If the initialization failed, the learner is informed of the failure. If the course has already been completed, the learner is informed of the prior completion.</p><p>Let&#x2019;s take a moment to further improve the user experience. Now that we&#x2019;ve established an on-screen location for providing feedback to the learner, we can replace all of the alert() calls with on-screen text. This is much less annoying than a parade of pop-up alerts. We can also hide the button after it has been clicked, since it&#x2019;s no longer needed and we don&#x2019;t want the learner to continue to click it.</p><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
   &lt;meta charset=&quot;UTF-8&quot;&gt;
   &lt;title&gt;SCORM Course&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;h1&gt;Course Title&lt;/h1&gt;

&lt;p&gt;Hello, &lt;span id=&quot;learner_name&quot;&gt;&lt;/span&gt;.&lt;/p&gt;

&lt;div id=&quot;message&quot;&gt;
Please click the button to indicate you have completed this course.
&lt;/div&gt;

&lt;button id=&quot;btn_complete&quot;&gt;Click me to complete this course&lt;/button&gt;

&lt;script&gt;

var API = window.API;

//Grab a reference to the learner_name span for future use
var name_span = document.querySelector(&quot;#learner_name&quot;);

//Grab a reference to the button for future use
var btn = document.querySelector(&quot;#btn_complete&quot;);

//Grab a reference to the &apos;message&apos; div for future use
var message_div = document.querySelector(&quot;#message&quot;);

//Set up a click handler for the button
btn.addEventListener(&quot;click&quot;, function(e){

   //Ensure the browser only does what we tell it to when the button is clicked
   e.preventDefault();

   if(isInitialized){

      //Get the lesson status from LMS
      var lesson_status = API.LMSGetValue(&quot;cmi.core.lesson_status&quot;);

      //Only set the status to &quot;completed&quot; if it is not already considered completed
      if(lesson_status !== &quot;completed&quot; &amp;&amp; lesson_status !== &quot;passed&quot;){

         //Explicitly set course status to &#x201C;completed&#x201D;
         API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);

         message_div.innerHTML = &quot;Course set to completed. Congratulations!&quot;;

         //Don&#x2019;t forget to commit!
         API.LMSCommit(&quot;&quot;);

      } else {

         message_div.innerHTML = &quot;Course has already been completed.&quot;;

      }

      //end the SCORM session
      API.LMSFinish(&quot;&quot;); 

   } else {

      message_div.innerHTML = &quot;Can&apos;t set the course to complete: it hasn&apos;t been initialized&quot;;

   }

   //hide the button so it can&apos;t be clicked anymore
   btn.style = &quot;display:none;&quot;;

});

//begin the SCORM session
var isInitialized = (API.LMSInitialize(&quot;&quot;) === &quot;true&quot;); //create a boolean value

if(isInitialized){

   //Get the learner&apos;s name from the LMS
   var learner_name = API.LMSGetValue(&quot;cmi.core.student_name&quot;);

   //Populate the learner_name span element with the learner&apos;s name
   name_span.innerHTML = learner_name;

} else {

   message_div.innerHTML = &quot;Uh-oh, LMSInitialize failed!&quot;;

}

&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>Three lines of JavaScript can make a big difference for the user experience.</p><h2 id="lesson-wrap-up">Lesson Wrap-Up</h2><p>And there you have it: a simple, usable SCORM course.</p><p><em>Again, we&#x2019;re using a fake API connection (window.API) for simplicity&#x2019;s sake, so this example will not function in a real LMS yet.</em></p><p>Granted, this example is terribly contrived and would not be very useful (or fun) in a real-world environment, but everything except the API connection is fully-functioning and valid.</p><p>We breezed through this exercise rather quickly, but I hope you get the gist of the lesson: SCORM code is just a tiny bit of JavaScript and nothing to be afraid of. In fact, I&#x2019;ll argue that the SCORM code within most e-learning courses is usually the smallest component of the codebase &#x2014; the course&#x2019;s presentation and navigation scripts are usually the most complex pieces of the puzzle.</p><p>Check the next few installments of this series for examples that will run in real-world LMS. These examples will utilize open-source JavaScript libraries to handle the presentation and navigation features, and we&#x2019;ll bolt on the SCORM code to make them LMS-ready.</p><p>Later in the series, when we&#x2019;ve established more of a comfort level with SCORM code and course-building concepts (bookmarking, scoring, etc.), we will build a simple-yet-complete HTML course from scratch.</p>]]></content:encoded></item><item><title><![CDATA[A Simple SCORM Example]]></title><description><![CDATA[<p>Let&#x2019;s roll up our sleeves and create a minimalist SCORM 1.2 course! This will be a fast and loose example, containing only the bare minimum of SCORM code and not much else. I will be moving quickly, but don&#x2019;t worry if you don&#x2019;t</p>]]></description><link>https://pipwerks.com/a-simple-scorm-example/</link><guid isPermaLink="false">680dcba37f494f0001d5f824</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Mon, 26 Aug 2024 13:09:40 GMT</pubDate><content:encoded><![CDATA[<p>Let&#x2019;s roll up our sleeves and create a minimalist SCORM 1.2 course! This will be a fast and loose example, containing only the bare minimum of SCORM code and not much else. I will be moving quickly, but don&#x2019;t worry if you don&#x2019;t grasp all of the details right away &#x2014; we will go through more realistic examples line-by-line later in this series.</p><h2 id="the-html">The HTML</h2><p>First, create a vanilla HTML page.</p><pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;title&gt;SCORM Course&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;&lt;/body&gt;
&lt;/html&gt;</code></pre><p>This is a barebones HTML5 document. Nothing special.</p><p>Next, we&#x2019;ll add a wee bit of JavaScript. SCORM courses use JavaScript to push and pull data from the LMS, such as the learner&#x2019;s name, course status, and a bookmark for learner&#x2019;s current location within the course. This is done by connecting to the SCORM application programming interface (API), which the LMS makes available in the browser&#x2019;s <code>window</code> object. (More on this later.)</p><p>Let&#x2019;s add a JavaScript <code>&lt;script&gt;</code> element to our HTML file. In the old days, this would have been placed in the <code>&lt;head&gt;</code>, but modern convention is to place it inside <code>&lt;body&gt;</code>, right before the <code>&lt;/body&gt;</code> tag.</p><p>(For you old-timers, I&#x2019;m using the HTML5 convention of <code>&lt;script&gt;</code> without type=&#x201C;text/javascript&#x201D; or lang=&#x201C;javascript&#x201D; &#x2014; HTML5 assumes every <code>&lt;script&gt;</code> block is JavaScript, so you no longer need to specify type=&#x201C;text/javascript&#x201D; or lang=&#x201C;javascript&#x201D;.)</p><pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;title&gt;SCORM Course&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;script&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><h2 id="the-javascript">The JavaScript</h2><p>As I mentioned above, the LMS makes the SCORM API available in the <code>window</code> JavaScript object. Since this is a contrived SCORM 1.2 example, we can pretend the SCORM 1.2 API is already available at <code>window.API</code>. In the real world, the API is typically in a parent frame or different browser window; you would never actually find the API in <code>window.API</code>. We will discuss the API in a later lesson, for now let&#x2019;s just pretend it&#x2019;s at <code>window.API</code>.</p><pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;title&gt;SCORM Course&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;script&gt;
      var API = window.API;
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>Here&#x2019;s where the fun starts. We will need to invoke the SCORM API to perform specific tasks. By spec, a SCORM course is only required to start (LMSInitialize) and stop (LMSFinish) a SCORM session. No other actions are required.</p><pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;title&gt;SCORM Course&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;script&gt;
      var API = window.API;
      //begin the SCORM session
      API.LMSInitialize(&quot;&quot;);
      //end the SCORM session
      API.LMSFinish(&quot;&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>Believe it or not, this is it &#x2014; a course that meets the minimum requirements for SCORM conformance!</p><h3 id="explicitly-setting-the-completion-status">Explicitly Setting the Completion Status</h3><p>Despite there being no attempt to set a course status or score, this minimalist code will normally result in a completion immediately upon launching the course. Why? By spec [SCORM Version 1.2 3-25], if the course does not manually specify a completion status value (via cmi.core.lesson_status) before the course session is terminated, the LMS is required to set the status to &#x201C;completed&#x201D;.</p><p>This brings us to a key premise of this tutorial series: <strong>LMSs don&#x2019;t always adhere to the SCORM spec</strong>. Working off of assumptions can be dangerous. In this example, we should play it safe and explicitly indicate the completion status, not trusting the LMS to set it for us.</p><pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;title&gt;SCORM Course&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;script&gt;
      var API = window.API;
      //begin the SCORM session
      API.LMSInitialize(&quot;&quot;);
      //Explicitly set course status to &#x201C;completed&#x201D;
      API.LMSSetValue(&quot;cmi.core.lesson_status&quot;, &quot;completed&quot;);
      //Don&#x2019;t forget to commit!
      API.LMSCommit(&quot;&quot;);
      //end the SCORM session
      API.LMSFinish(&quot;&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>LMSSetValue enables the course to set the value of a specific data field in the LMS. In this example, we&#x2019;re setting the value of the data field cmi.core.lesson<em>status to &#x201C;completed&#x201D;. LMSCommit specifically instructs the LMS to write the data into the LMS&#x2019;s database. Think of it as clicking the _save</em> button.</p><p>And this, in all its glory, is a bare-minimum HTML-based SCORM course.</p><p>Just remember, this is a contrived example and doesn&#x2019;t take any real-world concerns into account, such as what happens if window.API can&#x2019;t be found, or if LMSInitialize(&quot;&quot;) fails. <strong>This example is not ready for testing in an LMS yet.</strong> We&#x2019;ll get there, I promise!</p><p>In the next lesson, we&#x2019;ll modify this example to include lesson status, error-handling, and some personalization.</p><h2 id="notes">Notes</h2><ol><li>A course can only invoke LMSInitialize and LMSFinish once each; if the session is closed, it cannot be re-initialized. You would need to close the course and relaunch it from the LMS. Also note that due to the SCORM spec, an empty string must be passed for both LMSInitialize(&quot;&quot;) and LMSFinish(&quot;&quot;). Just one of those crazy SCORM quirks.</li><li>A word about LMSCommit: Whenever you set a value using LMSSetValue, you are sending the value to a JavaScript object in the web browser, not to the LMS itself. The data in the JavaScript object is not pushed to the server (the LMS&#x2019;s database) until you invoke API.LMSCommit(&quot;&quot;). Failure to invoke LMSCommit(&quot;&quot;) is a common blunder, causing an otherwise perfectly-crafted course to fail spectacularly.
    The flip side, however, is that if you invoke LMSCommit too often, it may &#x201C;clog the pipes&#x201D; and lead to performance issues with your course and/or LMS. This has been the case with some older off-the-shelf e-learning development tools. The best practice is to avoid invoking LMSCommit after every SetValue, and to strategically invoke it after a series of related SetValue calls. We will dive deeper in a future lesson.</li></ol>]]></content:encoded></item><item><title><![CDATA[SCORM: The Safe Parts]]></title><description><![CDATA[<p>SCORM has been around for 20+ years, and the ecosystem has matured to the point where we can make certain assumptions about vendor support.</p><p>Commercial course creation tools like Adobe Captivate, Articulate Rise, and iSpring are hyper-focused on broad compatibility with as many LMSs as possible. Acknowledging they couldn&#x2019;</p>]]></description><link>https://pipwerks.com/scorm-the-safe-parts/</link><guid isPermaLink="false">680dcba37f494f0001d5f821</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Mon, 05 Aug 2024 12:48:03 GMT</pubDate><content:encoded><![CDATA[<p>SCORM has been around for 20+ years, and the ecosystem has matured to the point where we can make certain assumptions about vendor support.</p><p>Commercial course creation tools like Adobe Captivate, Articulate Rise, and iSpring are hyper-focused on broad compatibility with as many LMSs as possible. Acknowledging they couldn&#x2019;t trust LMSs to provide full SCORM support, the tools were designed to utilize only the most commonly supported SCORM elements, which I refer to as the Safe Parts (<a href="https://www.oreilly.com/library/view/javascript-the-good/9780596517748/?ref=pipwerks.com">hat tip to Doug Crockford</a>). If you limit your course&#x2019;s use of SCORM to this subset of features, your course will work pretty much anywhere.</p><p>Note that my Safe Parts recommendations are based on SCORM 1.2, which has the greatest market share. If you prefer to use SCORM 2004, I consider the SCORM 2004 equivalents safe as well, though there can be some hiccups here and there depending on which version of SCORM 2004 has been implemented.</p><h2 id="api-safe-parts">API Safe Parts</h2><p>The API, owing to its simplicity, works pretty much as outlined in the SCORM documentation, and doesn&#x2019;t usually need any special handling.</p><h2 id="cmi-data-model-safe-parts">CMI Data Model Safe Parts</h2><p>The CMI data model has some truly useful fields. Unfortunately, most course development tools don&#x2019;t support SCORM&#x2019;s full CMI data model because of the old <em>chicken or egg</em> conundrum: LMSs don&#x2019;t consistently support the full CMI data model, so course vendors don&#x2019;t use it. Since course vendors don&#x2019;t use it, LMS vendors don&#x2019;t spend the time and money to implement it.</p><p>The following SCORM 1.2 CMI data fields are the closest thing you&#x2019;ll get to bulletproof in the LMS ecosystem.</p><ul><li><strong>cmi.core.student_id</strong> (read-only)A unique identifier for the learner who launched the course, provided by the LMS.String, 255 characters max.
</li><li><strong>cmi.core.student_name</strong> (read-only)Name of learner, as provided by the LMS.String, 255 characters max.
</li><li><strong>cmi.core.lesson_location</strong> (read-write)A string that can be used to &#x2018;bookmark&#x2019; the learner&#x2019;s location within a course.String, 255 characters max.
</li><li><strong>cmi.core.lesson_status</strong> (read-write)The status of the learner&#x2019;s progress for the course.Only accepts the following strings: &#x201C;passed&#x201D;, &#x201C;completed&#x201D;, &#x201C;failed&#x201D;, &#x201C;incomplete&#x201D;, &#x201C;browsed&#x201D;, &#x201C;not attempted&#x201D;.
</li><li><strong>cmi.core.score.min</strong> (read-write)cmi.core.score.min and cmi.core.score.max establish the range of scoring possible within the course, such as 0-100.Use cmi.core.score.min to specify the lowest possible score.Value must be a number, passed as a string.&#x201C;A number that may have a decimal point. Examples are &#x2018;2&#x2019;,&#x2019;2.2&#x2019;.&#x201D; (SCORM 1.2 Run-Time Environment, 3-57)

</li><li><strong>cmi.core.score.max</strong> (read-write)cmi.core.score.min and cmi.core.score.max establish the range of scoring possible within the course, such as 0-100.Use cmi.core.score.max to specify the highest possible score.Value must be a number, passed as a string.&#x201C;A number that may have a decimal point. Examples are &#x2018;2&#x2019;,&#x2019;2.2&#x2019;.&#x201D; (SCORM 1.2 Run-Time Environment, 3-57)

</li><li><strong>cmi.core.score.raw</strong> (read-write)Learner&#x2019;s current score.Must fall between the values established by cmi.core.score.min and cmi.core.score.raw.Value must be a number, passed as a string.&#x201C;A number that may have a decimal point. Examples are &#x2018;2&#x2019;,&#x2019;2.2&#x2019;.&#x201D; (SCORM 1.2 Run-Time Environment, 3-57)

</li><li><strong>cmi.core.exit</strong> (write-only)Indicates learner&#x2019;s reason for leaving the course.Only accepts the following strings: &#x201C;time-out&#x201D;, &#x201C;suspend&#x201D;, &#x201C;logout&#x201D;, &#x201C;&#x201D; (empty string).
</li><li><strong>cmi.suspend_data</strong> (read-write)Essentially a blank database field for storing anything you might need.Frequently used to store data about the course&#x2019;s state, such as which pages have been visited and which have not been accessed yet.String, 4096 characters max.LMSs are often lax on the cmi.suspend_data storage limit; some will allow much larger amounts of data. Do not assume if one LMS permits you to store over 4096 characters that other LMSs will follow suit.

</li></ul><p>Additionally, there are some fields that are likely safe, but should be tested in your LMS if you decide to use them:</p><ul><li><strong>cmi.core.entry</strong> (read-only)Indicates whether the learner is accessing the course for the first time (&#x201C;ab-initio&#x201D;) or is returning to an existing session (&#x201C;resume&#x201D;).Only returns the following strings: &#x201C;ab-initio&#x201D;, &#x201C;resume&#x201D;, &#x201C;&#x201D;.
</li><li><strong>cmi.core.session_time</strong> (write-only)The amount of time the student has spent in the course for the given session, from launch to closing the course. cmi.core.total_time provides a sum of all the individual sessions.String, formatted as HHHH:MM:SS.SS.&#x201C;Hours have a minimum of 2 digits and a maximum of 4 digits. Minutes shall consist of exactly 2 digits. Seconds shall contain 2 digits, with an optional decimal point and 1 or 2 additional digits. (i.e. 34.45).&#x201D; (SCORM 1.2 Run-Time Environment, 3-58)

</li><li><strong>cmi.core.total_time</strong> (read-only)&#x201C;Accumulated time of all the student&#x2019;s sessions in the SCO&#x201D;. (SCORM 1.2 Run-Time Environment, 3-29)String, formatted as HHHH:MM:SS.SS.&#x201C;Hours have a minimum of 2 digits and a maximum of 4 digits. Minutes shall consist of exactly 2 digits. Seconds shall contain 2 digits, with an optional decimal point and 1 or 2 additional digits. (i.e. 34.45).&#x201D; (SCORM 1.2 Run-Time Environment, 3-58)

</li></ul><p>I hesitate to include cmi.core.session_time and cmi.core.total_time; I&#x2019;ve seen LMSs that utilize their own codebase to track the learner&#x2019;s time in the course, ignoring values submitted by the course. Having said that, I do not see harm in using these fields, as long as you don&#x2019;t rely on them for critical course functionality.</p><h2 id="manifest-safe-parts">Manifest Safe Parts</h2><p>In courses produced by commercial tools like Captivate, the manifest is extremely simple. It&#x2019;s usually just a handful of XML nodes. This is because the course is a <em>monolithic</em> course &#x2014; a &#x2018;single SCO&#x2019; course, meaning it&#x2019;s completely self-contained, and manages its own internal navigation.</p><p>If you were to try to implement SCORM as originally envisioned, the navigation would need to be handled by the LMS, which would require listing every single HTML page in your course, plus navigation instructions (the sequencing and navigation piece). SCORM&#x2019;s Content Aggregation Model (CAM) took it even further, enabling SCOs (courses) to contain other SCOs and promoting &#x201C;<a href="https://adlnet.gov/past-projects/scorm/?ref=pipwerks.com#scorm-technical-details">reusability of learning content across LMSs and repositories</a>&#x201C;.</p><p>A grand vision, never embraced by enterprise LMS vendors.</p><p>For our purposes, the safest approach is to emulate commercial course development tools and implement a single-SCO methodology. We will only use the bare minimum, as outlined in this <a href="https://github.com/pipwerks/SCORM-Manifests/blob/master/SCORM%201.2%20Manifest/imsmanifest.xml?ref=pipwerks.com">template I posted on GitHub</a>.</p><pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; standalone=&quot;no&quot; ?&gt;
&lt;manifest identifier=&quot;CourseIDHere&quot; version=&quot;1&quot;
         xmlns=&quot;http://www.imsproject.org/xsd/imscp_rootv1p1p2&quot;
         xmlns:adlcp=&quot;http://www.adlnet.org/xsd/adlcp_rootv1p2&quot;
         xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
         xsi:schemaLocation=&quot;http://www.imsproject.org/xsd/imscp_rootv1p1p2 SCORM-schemas/imscp_rootv1p1p2.xsd
                             http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 SCORM-schemas/imsmd_rootv1p2p1.xsd
                             http://www.adlnet.org/xsd/adlcp_rootv1p2 SCORM-schemas/adlcp_rootv1p2.xsd&quot;&gt;
  &lt;metadata&gt;
    &lt;schema&gt;ADL SCORM&lt;/schema&gt;
    &lt;schemaversion&gt;1.2&lt;/schemaversion&gt;
  &lt;/metadata&gt;
  &lt;organizations default=&quot;course-code-here&quot;&gt;
    &lt;organization identifier=&quot;course-code-here&quot;&gt;
      &lt;title&gt;Course Title here&lt;/title&gt;
      &lt;item identifier=&quot;item_1&quot; identifierref=&quot;resource_1&quot;&gt;
        &lt;title&gt;Course Title here&lt;/title&gt;
      &lt;/item&gt;
    &lt;/organization&gt;
  &lt;/organizations&gt;
  &lt;resources&gt;
    &lt;resource identifier=&quot;resource_1&quot; type=&quot;webcontent&quot; adlcp:scormtype=&quot;sco&quot; href=&quot;index.html&quot;&gt;
      &lt;file href=&quot;index.html&quot; /&gt;
    &lt;/resource&gt;
  &lt;/resources&gt;
&lt;/manifest&gt;</code></pre><p>A working example will be provided later in this series.</p>]]></content:encoded></item><item><title><![CDATA[The Three SCORM Components You Need to Know]]></title><description><![CDATA[<p>For the purposes of course development, you will need to learn how to work with three components: the <strong>SCORM API</strong>, the <strong>CMI data model</strong>, and the <strong>course manifest</strong> (imsmanifest.xml).</p><p>Here&#x2019;s a quick overview of each component. I will provide real-world examples later in this series.</p><h2 id="the-scorm-api">The SCORM</h2>]]></description><link>https://pipwerks.com/the-three-scorm-components-you-need-to-know/</link><guid isPermaLink="false">680dcba37f494f0001d5f820</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Mon, 05 Aug 2024 12:27:29 GMT</pubDate><content:encoded><![CDATA[<p>For the purposes of course development, you will need to learn how to work with three components: the <strong>SCORM API</strong>, the <strong>CMI data model</strong>, and the <strong>course manifest</strong> (imsmanifest.xml).</p><p>Here&#x2019;s a quick overview of each component. I will provide real-world examples later in this series.</p><h2 id="the-scorm-api">The SCORM API</h2><p>The SCORM API is a JavaScript object made available through the browser&#x2019;s <code>window</code> object. The API allows the course to issue commands to the LMS, such as getting/setting data and exiting a course. The LMS is responsible for providing the API and exposing it via <code>window</code> to the course.</p><p>There are eight methods (functions) provided by the SCORM 1.2 API:</p><ol><li>LMSInitialize(&quot;&quot;)Starts the SCORM session.Returns a boolean in string format, indicating success of call.Quirk: Always requires a single parameter, set to an empty string.
</li><li>LMSFinish(&quot;&quot;)Ends the SCORM session.Returns a boolean in string format, indicating success of call.Quirk: Always requires a single parameter, set to an empty string.
</li><li>LMSGetValue(<em>cmi_field</em>)Retrieves and returns the value of the specified CMI field.Value provided in string format.
</li><li>LMSSetValue(<em>cmi_field, value</em>)Sets the value of the specified CMI field.All values must be submitted as a string, including numbers and objects (e.g. &#x201C;23&#x201D; instead of 23).Returns a boolean in string format, indicating success of call.
</li><li>LMSCommit(&quot;&quot;)Instructs the LMS to persist (save) the course data to the database.Returns a boolean in string format, indicating success of call.Quirk: Always requires a single parameter, set to an empty string.
</li><li>LMSGetLastError()Retrieves and returns the error code generated by the previous SCORM API call.Error code is a number provided in string format.
</li><li>LMSGetErrorString(<em>error_code</em>)Retrieves and returns a brief message describing the error specified by error_code.Message provided in string format.
</li><li>LMSGetDiagnostic(<em>error_code</em>)Retrieves and returns a more detailed message about the error specified by error_code.Message provided in string format.
</li></ol><p>SCORM 2004 renamed the API methods, but the API functionality is identical:</p><ul><li>Initialize()</li><li>Terminate()</li><li>GetValue()</li><li>SetValue()</li><li>Commit()</li><li>GetLastError()</li><li>GetErrorString()</li><li>GetDiagnostic()</li></ul><h2 id="the-cmi-data-model">The CMI Data Model</h2><p>SCORM is a reference model (hence the RM in SCORM), which means it repurposes existing standards. The CMI (computer managed instruction) data model was part of the <a href="https://en.wikipedia.org/wiki/Aviation_Industry_Computer-Based_Training_Committee?ref=pipwerks.com">AICC specification</a>, which preceded SCORM_._</p><p>The CMI data model can be thought of as a list of fields available in a database, including items like learner&#x2019;s name and the most recent course bookmark.</p><p>The meat of the SCORM system lies in the CMI data model, which provides course developers with access to specific bits of data, including the student&#x2019;s name via cmi.core.student_name, the score via cmi.core.score.raw, the course bookmark via cmi.core.lesson_location field, etc.</p><p>SCORM&#x2019;s CMI fields, especially with SCORM 2004, are actually pretty diverse and support many useful tasks: track course objectives, track user interactions (including answers provided by the learner, and how much time they spent in the interaction), gather comments from the learner as they progress through the course, and enable the learner to specify preferences about language, audio playback, and more.</p><p>You can explore the full list of available CMI fields on Rustici Software&#x2019;s handy <a href="https://scorm.com/scorm-explained/technical-scorm/run-time/run-time-reference/?ref=pipwerks.com">SCORM Run-Time Reference Guide</a>.</p><h2 id="the-course-manifest">The Course Manifest</h2><p>A SCORM course requires a manifest XML file, which contains key metadata such as course name, course ID, and version number. It also contains nodes informing the LMS where to find your course files.</p><p>The manifest is always named <em>imsmanifest.xml</em> and is always located at the root of the SCORM package.</p><p>The file is named <strong>ims</strong>manifest because it utilizes the <a href="https://edutechwiki.unige.ch/en/IMS_Content_Packaging?ref=pipwerks.com">IMS Global Learning Consortium&#x2019;s content packaging standards</a>. (Note the IMS Global Learning Consortium has rebranded to 1EdTech Consortium.)</p><p><a href="https://pipwerks.com/2014/02/17/clean-out-the-root-of-your-scorm-2004-package/">I&#x2019;ve previously written about manifests</a>, and <a href="https://github.com/pipwerks/SCORM-Manifests?ref=pipwerks.com">examples can be found on GitHub</a>. I will provide a functional example later in this series.</p>]]></content:encoded></item><item><title><![CDATA[A Brief History of SCORM]]></title><description><![CDATA[<p>The Shared Content Object Reference Model (SCORM) is a system for standardizing how e-learning courses interact with a learning management system (LMS).</p><p>In the mid-1990s, the Internet &#x2014; still spelled with a capital &#x201C;I&#x201D; &#x2014; was booming, and new learning management systems were popping up left and right.</p>]]></description><link>https://pipwerks.com/a-brief-history-of-scorm/</link><guid isPermaLink="false">680dcba37f494f0001d5f822</guid><category><![CDATA[SCORM]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Mon, 05 Aug 2024 03:43:21 GMT</pubDate><content:encoded><![CDATA[<p>The Shared Content Object Reference Model (SCORM) is a system for standardizing how e-learning courses interact with a learning management system (LMS).</p><p>In the mid-1990s, the Internet &#x2014; still spelled with a capital &#x201C;I&#x201D; &#x2014; was booming, and new learning management systems were popping up left and right. The LMS ecosystem was a bit of the Wild West, as each LMS tended to use its own proprietary code for tracking score, managing bookmarks, and sending course data to the LMS. Vendor lock-in made courses difficult to produce, maintain, and reuse. If you wanted to port the course from one LMS to another, you&#x2019;d need to rewrite the underlying tracking code (and course packaging) to be compatible with each LMS.</p><p>The US government, including the Department of Defense, recognized the inefficiencies and wanted to bring order to the chaos. In the late 1990s, they created a federal program called the <a href="https://adlnet.gov/about/history/?ref=pipwerks.com">Advanced Distributed Learning (ADL) Initiative</a> to create &#x201C;standards, tools, and learning content for the future learning environment.&#x201D;</p><p>Rather than reinvent the wheel, the ADL team opted to borrow fragments of existing standards to form their new reference model.</p><p><em>Note: Although SCORM is often referred to as a &#x201C;standard&#x201D;, it is in fact not a standard &#x2014; it&#x2019;s a curated collection of standards that had been previously created by other organizations. For example, the CMI data model is from the Aviation Industry Computer-Based Training Committee (AICC) group, and is an adaptation of an IEEE standard. SCORM&#x2019;s packaging system is from the IMS Global Learning Consortium, hence the SCORM package requiring a file named imsmanifest.xml.</em></p><p>SCORM 1.0 was released in January, 2000, providing guidance for standardizing the structure of e-learning course packages and the communication between the course and LMS. The result would be portable SCOs (shareable content objects, a.k.a. courses) that work in any SCORM-compliant LMS.</p><p>A simplistic, contrived example: Prior to SCORM, if the US Army purchased an e-learning course explaining how to use a fire extinguisher, the course could not be shared with the Navy, Air Force, or Marines because the course had been customized for the Army&#x2019;s learning management system. In this contrived scenario, each military branch used its own proprietary learning management system, none of which were compatible with the Army&#x2019;s course. They would have to either buy a different course to fill the same need, or pay a developer to modify the Army&#x2019;s existing course to work in their system(s).</p><p>By using SCORM to standardize communication and packaging techniques for e-learning courses, the courses become portable and reusable, reducing redundancies and likely saving a lot of time and money.</p><p>Since SCORM was a US government initiative, US federal agencies quickly added SCORM support as a requirement in their contracts, helping SCORM gain widespread adoption within a few years. Millions of government dollars were up for grabs, and the e-learning industry jumped at the opportunity. LMS vendors added SCORM support to their platforms, and course developers began producing SCORM packages.</p><p>SCORM 1.2 (released 2001) was the first widely adopted version of SCORM, and still holds the most market share. SCORM 1.3, more commonly known as SCORM 2004, was released a few years later, and was much more ambitious. It introduced some much-needed improvements to SCORM 1.2, but was also much more complex. SCORM 2004 included a new sequencing and navigation model that was incredibly difficult to implement, leading most LMS vendors to ignore it or (even worse) only support a subset of its features. SCORM 2004 has four editions, the most common of which are SCORM 2004 2nd Edition and SCORM 2004 3rd Edition. The first and fourth editions have very little market share.</p><p><a href="https://pipwerks.com/2011/03/10/is-scorm-dead">In early 2011 the ADL &#x201C;put a lid&#x201D; on SCORM</a>, making SCORM 2004 4th Edition the last official release. SCORM is no longer maintained or updated.</p><p>And that&#x2019;s the 60-second history of SCORM. Of course, there are many details I&#x2019;m leaving out for the sake of brevity, including SCORM&#x2019;s unintentional(?) enforcement of specific instructional models and pedagogies, but those are topics for another day.</p>]]></content:encoded></item><item><title><![CDATA[SCORM Tutorials]]></title><description><![CDATA[<p>I&#x2019;m about to publish a series of tutorials demonstrating how to build SCORM courses by hand.</p><p>Some may ask: Why hand-coded, and why SCORM?</p><p>Let&#x2019;s address the SCORM question first.</p><p>There are a dozen reasons why e-learning developers and instructional designers might scoff at SCORM: It&</p>]]></description><link>https://pipwerks.com/scorm-tutorials/</link><guid isPermaLink="false">680dcba37f494f0001d5f81f</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[SCORM]]></category><category><![CDATA[SCORM for Developers]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Sun, 04 Aug 2024 22:00:00 GMT</pubDate><content:encoded><![CDATA[<p>I&#x2019;m about to publish a series of tutorials demonstrating how to build SCORM courses by hand.</p><p>Some may ask: Why hand-coded, and why SCORM?</p><p>Let&#x2019;s address the SCORM question first.</p><p>There are a dozen reasons why e-learning developers and instructional designers might scoff at SCORM: It&#x2019;s outdated (replaced by CMI5 and xAPI); it reinforces the much-maligned &#x201C;page-turner&#x201D; approach and related instructional pedagogies; it forces learners and instructional designers to stay within the confines of the learning management system (a.k.a. the &#x201C;walled garden&#x201D;); it&#x2019;s not secure; it requires the learner to use a web browser instead of engaging with the real world; <em>yadda yadda yadda</em>.</p><p>Those arguments certainly have merit. However, there&#x2019;s no denying SCORM is deeply entrenched in the enterprise LMS world, and SCORM continues to be the primary format for corporate training courses. There&#x2019;s a reason products like Articulate Rise and Adobe Captivate continue to generate millions of dollars in annual revenue.</p><p>As for why hand-coded, there are hundreds, if not thousands, of developers who need to understand how to build a simple SCORM course without being locked into a vendor&#x2019;s ecosystem. Not everyone wants to use a rapid e-learning development app, and I&#x2019;m often asked for help by small teams trying to build SCORM courses with modern development stacks such as React and Vue.</p><p>These tutorials are for them.</p><p>The tutorials will be structured linearly, with a goal of being less like a reference manual and more conversational in nature. The series will be an organic walk-through of concepts and exercises, progressing from simplistic to increasingly sophisticated. Key concepts are introduced within the context of exercises; each exercise builds upon the previous exercise, often reusing code snippets, so it&#x2019;s a good idea to start with the first tutorial and work your way through each section.</p><p>By the end, you should be armed with the knowledge and tools you need to insert basic SCORM support into just about any HTML project.</p>]]></content:encoded></item><item><title><![CDATA[Installing Trax LRS on DigitalOcean]]></title><description><![CDATA[<p><a href="https://traxlrs.com/?ref=pipwerks.com">Trax</a> is a no-frills learning record store (LRS) designed for gathering and storing xAPI data. It&#x2019;s open-source and a great way to dip your toes into the world of the Experience API.</p><p>I wanted to take Trax for a spin, so I installed it on a DigitalOcean droplet.</p>]]></description><link>https://pipwerks.com/installing-trax-lrs-on-digitalocean/</link><guid isPermaLink="false">680dcba37f494f0001d5f818</guid><category><![CDATA[xAPI]]></category><category><![CDATA[digitalocean]]></category><category><![CDATA[LRS]]></category><category><![CDATA[TraxLRS]]></category><category><![CDATA[Tutorials]]></category><category><![CDATA[How-to]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Thu, 30 May 2024 14:52:17 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://traxlrs.com/?ref=pipwerks.com">Trax</a> is a no-frills learning record store (LRS) designed for gathering and storing xAPI data. It&#x2019;s open-source and a great way to dip your toes into the world of the Experience API.</p><p>I wanted to take Trax for a spin, so I installed it on a DigitalOcean droplet. My installation is configured for production (not limited to localhost) and includes a domain name and HTTPS. Here are the steps I used to install and configure Trax.</p><h2 id="create-an-ubuntu-droplet-in-digitalocean">Create an Ubuntu Droplet in DigitalOcean</h2><p>From your DigitalOcean account, click <em>Create</em> then select _Droplet_s. The Create Droplets page will appear.</p><p>Choose your region (I selected San Francisco), then choose Ubuntu. At the time of this writing, the latest long-term support edition is Ubuntu 24.04 LTS x64.</p><h3 id="droplet-size">Droplet Size</h3><p>To keep costs down, I selected a shared CPU with a regular SSD. If this were a true production system, I would have opted for a dedicated CPU. Trax LRS should work fine with the cheapest 2GB RAM package (50GB SSD, 2TB transfer). At the time of this writing, this configuration costs $12/month. The droplet specs can always be beefed up later if needed.</p><p>New to DigitalOcean? Use <a href="https://m.do.co/c/35dc5b0459a1?ref=pipwerks.com">my affiliate link</a> for $200 of free credit!</p><h3 id="authentication-method">Authentication Method</h3><p>The authentication method is up to you, but I use the SSH key option for convenience.</p><h3 id="finalize-details">Finalize Details</h3><p>Edit the droplet&#x2019;s Hostname field to whatever works for you. This is just for your own reference within your DigitalOcean account. Click the <em>Create Droplet</em> button to complete the process.</p><p>Once the droplet has been generated, you will see its IP address in your DigitalOcean dashboard. Make a note of the IP address, we will need it in future steps.</p><h2 id="configure-your-domain-name">Configure your domain name</h2><p>Go to your registrar of choice (I use <a href="https://porkbun.com/?ref=pipwerks.com">porkbun.com</a>) and configure your domain as needed. Use the droplet&#x2019;s IP address for the A record. I won&#x2019;t be providing instructions for configuring the domain, as each registrar uses slightly different workflows.</p><h2 id="install-trax">Install Trax</h2><h3 id="connect-to-the-droplet-via-ssh-and-prep-the-system">Connect to the droplet via SSH and prep the system</h3><p>SSH to your droplet using your terminal of choice (I use <a href="https://app.warp.dev/referral/3DRQE3?ref=pipwerks.com">Warp</a>) and your droplet&#x2019;s IP address. For example:</p><pre><code class="language-bash">ssh root@123.123.123.123</code></pre><p><em>Note: For simplicity, this tutorial will be running commands as the root user. The best practice in Linux environments is to create an admin user on the system, SSH into the system as that user, then run the same commands, but prefixed with sudo. If you plan to use these instructions for a true production environment, please set up an admin account and avoid using root.</em></p><p>Once connected, ensure the system is up to date by running</p><pre><code class="language-bash">apt update &amp;&amp; apt upgrade</code></pre><h3 id="install-dependencies">Install dependencies</h3><p>Trax has a number of dependencies, including PHP and a database (I&#x2019;m using MariaDB instead of MySQL). Install the dependencies using the following command:</p><pre><code class="language-bash">apt install apache2 php libapache2-mod-php php-mysql php-curl php-xml php-mbstring php-zip php-gd mariadb-server git composer certbot python3-certbot-apache</code></pre><p><em>Note: If this were a true production system, I would use a dedicated database server.</em></p><h3 id="harden-mariadb">Harden MariaDB</h3><p>For safety, let&#x2019;s take a moment to secure the MariaDB installation.</p><p>Run the following command, then follow the prompts as needed. If you elect to configure a password for the MariaDB root user, be sure to write it down as you will need it later.</p><pre><code class="language-bash">mysql_secure_installation</code></pre><h3 id="install-the-trax-files">Install the Trax files</h3><p>Grab the Trax files from its GitHub repository, then run the Trax install script.</p><pre><code class="language-bash">git clone https://github.com/trax-project/trax2-starter-lrs /var/www/traxlrs
cd /var/www/traxlrs
composer install</code></pre><p><em>Note: As previously mentioned, this tutorial is using the Ubuntu root account. If you&#x2019;re following the tutorial step by step, you will see a warning about running composer as a root user. For this demo, it&#x2019;s safe to ignore the warning. If you&#x2019;re setting up a true production environment, you should not be using root.</em></p><h3 id="set-permissions">Set permissions</h3><p>Now that the Trax files and directories are in place, we need to ensure permissions are correct. I used the following configuration, though you may be able to lock it down further, if desired:</p><pre><code class="language-bash">chown -R www-data:www-data storage bootstrap/cache
chmod -R 755 storage
chmod -R 755 bootstrap/cache</code></pre><h3 id="configure-apache">Configure Apache</h3><p>There are several things that need to be configured for Apache. First, let&#x2019;s configure the virtual host:</p><pre><code class="language-bash">nano /etc/apache2/sites-available/traxlrs.conf</code></pre><p>This will bring up the virtual host settings in the nano text editor. Insert the following snippet. Be sure to modify the ServerAdmin and ServerName fields for your environment:</p><pre><code class="language-xml">&lt;VirtualHost *:80&gt;
    ServerAdmin admin@example.com
    DocumentRoot /var/www/traxlrs/public
    ServerName example.com
    &lt;Directory /var/www/traxlrs/public&gt;
        AllowOverride All
        Require all granted
    &lt;/Directory&gt;
&lt;/VirtualHost&gt;</code></pre><p>Save the file and exit nano. (If you&#x2019;re unfamiliar with nano, you can press control-x on your keyboard to exit, which prompts you to save changes. Type Y to save changes, then press Enter to save the file using the existing file name.)</p><p>Next, enable the site and rewrite module:</p><pre><code class="language-bash">a2ensite traxlrs.conf
a2enmod rewrite
systemctl restart apache2</code></pre><h3 id="configure-the-database">Configure the database</h3><p>MariaDB was installed, but we haven&#x2019;t set up the Trax database yet. Log in to MariaDB:</p><pre><code class="language-bash">sudo mysql -u root -p</code></pre><p>If you created a password for the MariaDB root user, enter it now.</p><p>You should see the SQL prompt in your terminal. Edit the following to use a proper password for &#x2018;traxuser&#x2019;, then paste into the SQL prompt and press enter.</p><pre><code class="language-bash">CREATE DATABASE traxlrs CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER &apos;traxuser&apos;@&apos;localhost&apos; IDENTIFIED BY &apos;YourSecurePassword&apos;;
GRANT ALL PRIVILEGES ON traxlrs.* TO &apos;traxuser&apos;@&apos;localhost&apos;;
FLUSH PRIVILEGES;
EXIT;</code></pre><h3 id="update-the-environment-file">Update the environment file</h3><p>Trax comes with a sample environment file. It&#x2019;s a great starting point, so let&#x2019;s copy it, then edit it to fit our needs:</p><pre><code class="language-bash">cp .env.example .env
nano .env</code></pre><p>In the nano editor, update the file to reflect the latest info. Be sure to include your actual database password.</p><pre><code class="language-bash">DB_DATABASE=traxlrs
DB_USERNAME=traxuser
DB_PASSWORD=YourSecurePassword</code></pre><p>We&#x2019;ll be coming back to the environment file in a moment. For now, save and exit.</p><h3 id="finalize-trax-installation">Finalize Trax installation</h3><p>Run the following commands to finalize the Trax installation:</p><pre><code class="language-bash">php artisan key:generate
php artisan migrate</code></pre><p>Let&#x2019;s create a Trax admin account:</p><pre><code class="language-bash">php artisan admin:create</code></pre><h3 id="set-environment-to-production">Set environment to production</h3><p>Since this is a public-facing installation and not running locally on your machine, we need to update the environment file to production values. Open the .env file again:</p><pre><code class="language-bash">nano .env</code></pre><p>Then update the following values:</p><pre><code class="language-bash">APP_ENV=production
APP_DEBUG=false</code></pre><p>Save and exit.</p><h3 id="optimize-the-installation">Optimize the installation</h3><p>Run these commands to do some quick optimization:</p><pre><code class="language-bash">php artisan config:cache
php artisan route:cache</code></pre><h3 id="enable-https">Enable HTTPS</h3><p>We&#x2019;re almost done! The Trax system should be up and running, but we should ensure it&#x2019;s using HTTPS. We&#x2019;ll use Let&#x2019;s Encrypt, which makes HTTPS easy. Run the following command then follow the prompts. Be sure to use your actual domain name, not example.com!</p><pre><code class="language-bash">certbot --apache -d example.com</code></pre><p>Once you&#x2019;ve completed the certbot prompts, your Trax system should be up and running at your specified domain.</p>]]></content:encoded></item><item><title><![CDATA[Plausible.io: An easy (and ethical) Google Analytics alternative]]></title><description><![CDATA[<p>I&#x2019;ve used Google Analytics for probably around 15 years. That&#x2019;s a long time. Google Analytics has always felt like a deal with the devil: It&#x2019;s such a convenient way to get basic site stats &#x2014; how many people visit, which pages are getting the</p>]]></description><link>https://pipwerks.com/plausible-io-an-easy-and-ethical-google-analytics-alternative/</link><guid isPermaLink="false">680dcba37f494f0001d5f816</guid><category><![CDATA[Google analytics]]></category><category><![CDATA[Plausible.io]]></category><category><![CDATA[matomo]]></category><category><![CDATA[Tips and Tricks]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Fri, 26 Apr 2024 14:34:22 GMT</pubDate><content:encoded><![CDATA[<p>I&#x2019;ve used Google Analytics for probably around 15 years. That&#x2019;s a long time. Google Analytics has always felt like a deal with the devil: It&#x2019;s such a convenient way to get basic site stats &#x2014; how many people visit, which pages are getting the most visits, and basic demographics such as region and browser/OS, etc. &#x2014; but at what cost? I&#x2019;ve never felt comfortable contributing to Google&#x2019;s insatiable appetite for personal data.</p><p>And, hypocritically, I am a strong proponent for ad blockers that prevent sites from tracking my visits.</p><p>Coupled with the increasingly poor Google Analytics administrative experience (more confusing, more bloated, overemphasis on monetization features I don&#x2019;t need) and confusing support for GDPR, it prompted me to look for alternatives.</p><p>This isn&#x2019;t the first time I&#x2019;ve tried something else. Around 2014, I switched to <a href="https://matomo.org/?ref=pipwerks.com">Matomo</a> (formerly piwik), an open-source self-hosted clone of Google Analytics. While it worked OK, it became too much to manage, and after a couple of years, I begrudgingly switched back to Google Analytics.</p><p>Now, nearly a decade later, I find myself wanting to untangle from Google again, and figured Matomo would be a mature solution worth revisiting. Unfortunately, Matomo is still heavier than I&#x2019;d like. I don&#x2019;t really want to self-host anymore, and Matomo was proving to be a bit complicated to integrate into Vue-based projects. I was also not looking forward to implementing new GDPR-compliant &#x2018;click to consent to cookies&#x2019; prompts.</p><p>While researching Matomo, I ran across a discussion of <a href="https://plausible.io/?ref=pipwerks.com">plausible.io</a>&#x2019;s approach to privacy. I had never heard of Plausible, but was immediately impressed by their pitch: they&#x2019;re based in Germany, and by default only collect anonymized data, meaning they&#x2019;re GDPR compliant out of the box, and no &#x2018;cookie consent&#x2019; prompts are required. While Plausible does get blocked by some ad blockers, its focus on privacy is helping them slowly get allow-listed by ad block vendors, meaning Plausible&#x2019;s analytics scripts are less likely to be blocked than Google&#x2019;s.</p><p>Plausible feels very reminiscent of <a href="https://proton.me/?ref=pipwerks.com">Proton</a>, a privacy-focused suite of products in Switzerland designed to help people decouple from Gmail. Like Proton, Plausible isn&#x2019;t free, but that&#x2019;s because they&#x2019;re not scanning or selling your data. Both Proton and Plausible are very affordable, and the modest fees help them continue to provide the service. (Shameless plug: Here&#x2019;s a <a href="https://pr.tn/ref/X4KHFWKFF86G?ref=pipwerks.com">referral link for Proton</a> if you&#x2019;d like to try a month of Proton&#x2019;s Mail Plus for free!)</p><p>I signed up for a free 30-day trial of Plausible, and was impressed with the simplified dashboard and ability to import historical Google Analytics data. So much so that I immediately decided to switch platforms, moving this blog, <a href="https://pdfobject.com/?ref=pipwerks.com">PDFObject.com</a>, and some of my other properties to Plausible.</p><p>Time will tell how wise the decision is, but I&#x2019;m already enjoying the Plausible experience much more than Google Analytics. It&#x2019;s more streamlined, and doesn&#x2019;t nag me to monetize or integrate with an ad platform. Plausible supports funnels, goals, and campaigns if I ever decide to incorporate marketing into my sites, but by default is a nice bare-bones way to get site stats without compromising user privacy. And that alone makes Plausible worth a shot.</p>]]></content:encoded></item><item><title><![CDATA[Say hello to fretboard.buzz]]></title><description><![CDATA[<p>Last week, I released <a href="https://fretboard.buzz/?ref=pipwerks.com">https://fretboard.buzz</a>, a free online tool that helps luthiers design fretboards for guitar, bass, mandolin, ukulele, and more.</p><p>I&#x2019;ve been playing guitar since I was a teenager in the late 80s, and got the bug for building guitars circa 2018. A chance encounter</p>]]></description><link>https://pipwerks.com/say-hello-to-fretboard-buzz/</link><guid isPermaLink="false">680dcba37f494f0001d5f817</guid><category><![CDATA[Tools]]></category><category><![CDATA[vue]]></category><category><![CDATA[web design and development]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Thu, 25 Apr 2024 10:28:47 GMT</pubDate><content:encoded><![CDATA[<p>Last week, I released <a href="https://fretboard.buzz/?ref=pipwerks.com">https://fretboard.buzz</a>, a free online tool that helps luthiers design fretboards for guitar, bass, mandolin, ukulele, and more.</p><p>I&#x2019;ve been playing guitar since I was a teenager in the late 80s, and got the bug for building guitars circa 2018. A chance encounter with a YouTube video gave me the crazy idea that instead of buying guitars, I should build them! My priorities immediately shifted: outside of my job and my time with my family, I used to mostly divide my time between playing guitar, working on code, or watching San Francisco Giants games (sometimes all three at once). Once I got into guitar building, I spent most of my free time watching videos and reading articles about building guitars. I would still write code and play with the latest tech, but at a much slower clip, hence the lack of posts on pipwerks.com.</p><p>(Shameless plug: <a href="https://www.youtube.com/watch?v=ar_4iTR2jpo&amp;ref=pipwerks.com">Here&#x2019;s a video documenting one of my guitar builds</a>&#x2026; I made a guitar out of an IKEA butcher block countertop!)</p><p>In December 2023, another YouTube video sent me down another rabbit hole: <a href="http://www.highlineguitars.com/?ref=pipwerks.com">Chris Monck from Highline Guitars</a> described <a href="https://www.youtube.com/watch?v=O_32e3vFjms&amp;ref=pipwerks.com">his process for designing a guitar fretboard</a>, which began in <a href="https://acspike.github.io/FretFind2D/?ref=pipwerks.com">FretFind2D</a>, and then moved to Adobe Illustrator, where he painstakingly re-drew the fretboard produced by FretFind2D. Being a developer, I knew it was possible to make a better SVG than what was being produced by FretFind2D, so I rolled up my sleeves and got to work.</p><p>Being an open-source project, my initial thought was to fork FretFind2D and improve the SVG and PDF handling. However, as I dug into the code, which was written back in 2010, there were simply too many things to fix, from outdated jQuery to inaccessible form fields and the odd bug. I found myself rewriting entire sections of JavaScript, wanting to modernize the code style, wanting to completely redesign the UI, and getting ideas for new features that would be difficult to implement in the FretFind2D environment. It became too much. I decided it would be a better use of my time to write a new system from scratch using a modern reactive framework.</p><p>(Note: This is not a knock on FretFind2D, which is still an amazing piece of work!)</p><p>I prefer <a href="https://vuejs.org/?ref=pipwerks.com">Vue</a> over React and Angular, so I started working in Vue 3. This would enable me to create a much better user experience, and would also give me hands-on time with the latest web technologies, including Pinia for state management and Tailwind CSS for styling. I also befriended Chat GPT and Claude.ai, who helped me with my questionable geometry and trigonometry skills.</p><p>This project took me much longer than anticipated; I easily spent over 150 hours over the course of a few months working on it. I was learning as I went &#x2014; and I learned a LOT &#x2014; but I was also constantly coming up with new ideas and features, many of which required additional research or rewriting swaths of code. In total, the project is about 14,000 lines of code spread over 91 files.</p><p>I&#x2019;m very pleased with the result. The SVG is rendered by Vue, and is updated in real-time whenever the user tweaks parameters. The SVG can be downloaded as an SVG or PDF, and settings can be downloaded as JSON for future use. <a href="https://fretboard.buzz/about?ref=pipwerks.com">Some of my favorite features are outlined on the About page</a>.</p><p>I usually release my projects as open-source, but I&#x2019;ve decided not to open-source this one. It&#x2019;s free to use at <a href="https://fretboard.buzz/?ref=pipwerks.com">https://fretboard.buzz</a>, but I&#x2019;m retaining the copyright and deciding next steps. As always, I have a few ideas!</p><p>If you try the site and have any feedback, please hit me up on my <a href="https://www.instagram.com/theweekendluthier/?ref=pipwerks.com">Weekend Luthier Instagram profile</a>.</p>]]></content:encoded></item><item><title><![CDATA[PDFObject 2.3 released, new site]]></title><description><![CDATA[<p>PDFObject 2.3.0 has been released and is available immediately on NPM and related CDNs. It continues to support all major browsers (even that pesky little IE11).</p><p>There are quite a few improvements in 2.3, including switching to a pure iframe solution for more robust cross-browser support, and</p>]]></description><link>https://pipwerks.com/pdfobject-2-3-released-new-site/</link><guid isPermaLink="false">680dcba37f494f0001d5f81b</guid><category><![CDATA[PDFObject]]></category><category><![CDATA[Project Updates]]></category><dc:creator><![CDATA[Philip Hutchison]]></dc:creator><pubDate>Wed, 14 Feb 2024 17:00:04 GMT</pubDate><content:encoded><![CDATA[<p>PDFObject 2.3.0 has been released and is available immediately on NPM and related CDNs. It continues to support all major browsers (even that pesky little IE11).</p><p>There are quite a few improvements in 2.3, including switching to a pure iframe solution for more robust cross-browser support, and improved handling of PDF Open Parameters. See the <a href="https://pdfobject.com/api/changelog.html?ref=pipwerks.com">changelog</a> for more details.</p><p>I&#x2019;ve also revamped <a href="https://pdfobject.com/?ref=pipwerks.com">pdfobject.com</a>. It now features more thorough documentation and examples. Thanks to its new VitePress backend, it should load quickly and is fully searchable. The new <a href="https://pdfobject.com/guide/browser-support.html?ref=pipwerks.com">Browser Support</a> page goes into great detail about which browsers support inline PDFs, as well as a handy reference table for checking which PDF Open Parameters are supported in each major browser platform.</p>]]></content:encoded></item></channel></rss>