diff --git a/build.scala b/build.scala
index 53f1a3ea..c7c096bc 100644
--- a/build.scala
+++ b/build.scala
@@ -80,7 +80,10 @@ object LaikaBuild {
import pink.cozydev.protosearch.laika.IndexConfig
import pink.cozydev.protosearch.ui.SearchUI
- val indexConfig = IndexConfig.default
+ // The interactive tutorials are an unlisted prototype; keep them out of
+ // search until they are ready to be announced.
+ val indexConfig =
+ IndexConfig.default.withExcludedPaths(Path.Root / "tutorials")
def input = {
val securityPolicy = new URI(
diff --git a/src/tutorials/README.md b/src/tutorials/README.md
new file mode 100644
index 00000000..53e1b3e0
--- /dev/null
+++ b/src/tutorials/README.md
@@ -0,0 +1,42 @@
+{%
+ laika.title = Tutorials
+%}
+
+# Typelevel Tutorials
+
+Welcome! These tutorials are hands-on: each lesson explains an idea on the left side
+of the page and gives you a live Scala editor on the other. Edit the code, press
+**Run**, and see what happens.
+
+@:style(bulma-notification bulma-is-warning bulma-is-light)
+ This section is an early prototype. The lessons below exist to show that the
+ machinery works; their content is simply a few vibe-coded example pages. They
+ should be replaced by human-curated tracks that say what we want to say.
+@:@
+
+## Cats
+
+[Cats](https://typelevel.org/cats/) is the foundation of the Typelevel
+ecosystem: a library of type classes and data types for functional programming
+in Scala.
+
+@:navigationTree {
+ entries = [
+ { target = "/tutorials/cats", excludeRoot = true, excludeSections = true }
+ ]
+}
+
+## Cats Effect
+
+[Cats Effect](https://typelevel.org/cats-effect/) builds on Cats to describe
+side effects as values, and gives you a runtime to execute them.
+
+@:navigationTree {
+ entries = [
+ { target = "/tutorials/cats-effect", excludeRoot = true, excludeSections = true }
+ ]
+}
+
+These tutorials were inspired by [scalatutorials](https://scalatutorials.com), which teaches
+the Scala language itself. If you're just starting out, we recommend check that out first,
+then come back here to help dive into Typelevel!
diff --git a/src/tutorials/cats-effect/directory.conf b/src/tutorials/cats-effect/directory.conf
new file mode 100644
index 00000000..ecea4767
--- /dev/null
+++ b/src/tutorials/cats-effect/directory.conf
@@ -0,0 +1,3 @@
+laika.title = Cats Effect
+laika.html.template = /tutorials/tutorial.template.html
+laika.navigationOrder = [io.md, unsafe-run.md]
diff --git a/src/tutorials/cats-effect/io.md b/src/tutorials/cats-effect/io.md
new file mode 100644
index 00000000..cd34586b
--- /dev/null
+++ b/src/tutorials/cats-effect/io.md
@@ -0,0 +1,19 @@
+{%
+ laika.title = IO
+ scastie.code = io.sc
+%}
+
+An `IO[A]` is a *description* of a computation that may perform side effects and
+eventually produce an `A`. Building one runs nothing at all: `IO(println("hi"))`
+is a value you can pass around, store in a list, or throw away.
+
+That is the whole idea. Once effects are values, the ordinary tools work on
+them: you can `map` and `flatMap` them into bigger programs, combine them in a
+`for` comprehension, and repeat one simply by using it twice.
+
+Notice in the editor that nothing is printed while the `IO`s are being built.
+The description is inert until somebody runs it — which is the subject of the
+next lesson.
+
+**Try it:** reorder the steps in the `for` comprehension, or build an `IO` and
+never use it.
diff --git a/src/tutorials/cats-effect/io.sc b/src/tutorials/cats-effect/io.sc
new file mode 100644
index 00000000..b55dc9da
--- /dev/null
+++ b/src/tutorials/cats-effect/io.sc
@@ -0,0 +1,19 @@
+//> using dep org.typelevel::cats-effect:3.6.3
+
+import cats.effect.IO
+
+// building an IO performs no effect: this line prints nothing
+val hello: IO[Unit] = IO(println("hello"))
+
+// bigger programs are built out of smaller ones
+val program: IO[Unit] =
+ for
+ _ <- hello
+ _ <- IO(println("and again"))
+ n <- IO(21 * 2)
+ _ <- IO(println(s"the answer is $n"))
+ yield ()
+
+// still nothing has happened; `program` is only a description
+println("built the program, and nothing has run yet")
+println(program)
diff --git a/src/tutorials/cats-effect/unsafe-run.md b/src/tutorials/cats-effect/unsafe-run.md
new file mode 100644
index 00000000..f6429025
--- /dev/null
+++ b/src/tutorials/cats-effect/unsafe-run.md
@@ -0,0 +1,23 @@
+{%
+ laika.title = Running an IO
+ scastie.code = unsafe-run.sc
+%}
+
+Descriptions have to be executed eventually. In a real application that happens
+once, at the edge of the world: you extend `IOApp`, hand it your program, and
+Cats Effect runs it on its own runtime.
+
+The `unsafeRun*` methods let you do it by hand. They are called *unsafe* not
+because they are broken, but because they break the property that makes `IO`
+worth having: they actually perform effects, and they can block or throw. Every
+call is a place where functional reasoning stops.
+
+- `unsafeRunSync()` blocks the current thread until the program finishes.
+- `unsafeRunAndForget()` starts it and returns immediately.
+- `unsafeToFuture()` hands you a `Future` of the result.
+
+Running the same description twice performs the effects twice: an `IO` is a
+recipe, not a cached result.
+
+**Try it:** run `program` a second time, or comment out the `unsafeRunSync()`
+call and watch the output disappear.
diff --git a/src/tutorials/cats-effect/unsafe-run.sc b/src/tutorials/cats-effect/unsafe-run.sc
new file mode 100644
index 00000000..e61fa9ce
--- /dev/null
+++ b/src/tutorials/cats-effect/unsafe-run.sc
@@ -0,0 +1,17 @@
+//> using dep org.typelevel::cats-effect:3.6.3
+
+import cats.effect.IO
+import cats.effect.unsafe.implicits.global
+
+val program: IO[Int] =
+ for
+ _ <- IO(println("starting"))
+ n <- IO(21 * 2)
+ _ <- IO(println(s"finished with $n"))
+ yield n
+
+// nothing above this line has printed anything
+println("about to run")
+
+val result = program.unsafeRunSync()
+println(s"got back $result")
diff --git a/src/tutorials/cats/directory.conf b/src/tutorials/cats/directory.conf
new file mode 100644
index 00000000..a8bc5eb3
--- /dev/null
+++ b/src/tutorials/cats/directory.conf
@@ -0,0 +1,3 @@
+laika.title = Cats
+laika.html.template = /tutorials/tutorial.template.html
+laika.navigationOrder = [functor.md, monoid.md]
diff --git a/src/tutorials/cats/functor.md b/src/tutorials/cats/functor.md
new file mode 100644
index 00000000..30dd41ff
--- /dev/null
+++ b/src/tutorials/cats/functor.md
@@ -0,0 +1,23 @@
+{%
+ laika.title = Functor
+ scastie.code = functor.sc
+%}
+
+A `Functor` is anything you can `map` over: a container or a context whose
+contents can be transformed without changing the shape of the context itself.
+`List`, `Option` and `Either` are all functors, and so are many types that don't
+look like collections at all.
+
+Cats captures this as a type class with a single abstract method:
+
+```scala
+trait Functor[F[_]]:
+ def map[A, B](fa: F[A])(f: A => B): F[B]
+```
+
+Because the type class is abstract in `F`, you can write code once and run it
+against every functor. The `plusOne` method in the editor never mentions `List`
+or `Option`, yet it works with both.
+
+**Try it:** call `plusOne` with a `Vector`, or map an `Option` that is `None`
+and see what comes back.
diff --git a/src/tutorials/cats/functor.sc b/src/tutorials/cats/functor.sc
new file mode 100644
index 00000000..62443358
--- /dev/null
+++ b/src/tutorials/cats/functor.sc
@@ -0,0 +1,15 @@
+//> using dep org.typelevel::cats-core:2.13.0
+
+import cats.Functor
+import cats.syntax.all.*
+
+// `map` for any functor at all, chosen by the compiler from the type
+def plusOne[F[_]: Functor](fa: F[Int]): F[Int] =
+ fa.map(_ + 1)
+
+println(plusOne(List(1, 2, 3)))
+println(plusOne(Option(41)))
+println(plusOne(Option.empty[Int]))
+
+// the type class instance is also available directly
+println(Functor[List].map(List("cats", "effect"))(_.toUpperCase))
diff --git a/src/tutorials/cats/monoid.md b/src/tutorials/cats/monoid.md
new file mode 100644
index 00000000..d391e9d4
--- /dev/null
+++ b/src/tutorials/cats/monoid.md
@@ -0,0 +1,23 @@
+{%
+ laika.title = Monoid
+ scastie.code = monoid.sc
+%}
+
+A `Monoid` is a type that knows how to combine two of its values, and what an
+"empty" value looks like:
+
+```scala
+trait Monoid[A]:
+ def empty: A
+ def combine(x: A, y: A): A
+```
+
+Combining must be associative, and `empty` must not change anything it is
+combined with. That is enough structure to fold a whole collection without
+writing a fold: Cats gives you `combineAll` for free.
+
+Numbers, strings, lists and maps are all monoids — and so is any tuple or map
+whose contents are monoids, which is where it starts to pay off.
+
+**Try it:** combine a `List[Map[String, Int]]` and watch the values under
+duplicate keys get combined rather than overwritten.
diff --git a/src/tutorials/cats/monoid.sc b/src/tutorials/cats/monoid.sc
new file mode 100644
index 00000000..3f91614f
--- /dev/null
+++ b/src/tutorials/cats/monoid.sc
@@ -0,0 +1,17 @@
+//> using dep org.typelevel::cats-core:2.13.0
+
+import cats.Monoid
+import cats.syntax.all.*
+
+// `combine` (aliased to |+|) and `empty` come from the Monoid instance
+println(1 |+| 2)
+println("type" |+| "level")
+println(Monoid[List[Int]].empty)
+
+// folding a collection with its monoid
+println(List(1, 2, 3, 4).combineAll)
+println(List("a", "b", "c").combineAll)
+
+// tuples and maps are monoids when their contents are
+println(List(("cats", 1), ("cats", 2)).combineAll)
+println(List(Map("a" -> 1), Map("a" -> 2, "b" -> 3)).combineAll)
diff --git a/src/tutorials/directory.conf b/src/tutorials/directory.conf
new file mode 100644
index 00000000..60e383be
--- /dev/null
+++ b/src/tutorials/directory.conf
@@ -0,0 +1,30 @@
+# Interactive tutorials (prototype).
+#
+# How a tutorial works:
+# * every lesson is a markdown file whose prose is rendered into the left
+# pane by /tutorials/tutorial.template.html
+# * next to it lives a `.sc` file with the sample code, which tutorials.js
+# loads into an embedded Scastie editor in the right pane (the scala-cli
+# script extension, since the samples are worksheet-style: top-level
+# statements plus `//> using` directives, runnable with `scala run`)
+# * each track is a subdirectory; its directory.conf points the lessons at
+# the tutorial template and fixes their order
+#
+# Restricting the target formats to HTML keeps these pages out of the search
+# index and the RSS feed, so the prototype stays unlisted (nothing on the site
+# links to /tutorials either) until we decide it is ready to announce.
+laika.targetFormats = [html]
+
+# Defaults for the embedded Scastie editor. Tracks and individual lessons can
+# override any of these. Dependencies are declared with `//> using` directives
+# in the lesson's code file, which is why the target is Scala CLI.
+#
+# The target names Scastie accepts are "scala-cli", "scala3", "jvm" (Scala 2),
+# "typelevel", "js" and "native"; anything else is quietly ignored and you end
+# up with a plain sbt Scala 3 editor where `//> using` does nothing. Leaving
+# scala-version empty takes Scastie's current default.
+scastie {
+ target-type = "scala-cli"
+ scala-version = ""
+ worksheet = true
+}
diff --git a/src/tutorials/tutorial.template.html b/src/tutorials/tutorial.template.html
new file mode 100644
index 00000000..e369e259
--- /dev/null
+++ b/src/tutorials/tutorial.template.html
@@ -0,0 +1,61 @@
+@:embed(/templates/main.template.html)
+
+
+
+
+
+
+
+
${cursor.currentDocument.title}
+ ${cursor.currentDocument.content}
+
+
+
+
+
+
+
+ Try it yourself
+
+
+
+
Loading the editor…
+
+
+
+
+
+
+
+
+@:@
diff --git a/src/tutorials/tutorials.css b/src/tutorials/tutorials.css
new file mode 100644
index 00000000..9d575824
--- /dev/null
+++ b/src/tutorials/tutorials.css
@@ -0,0 +1,245 @@
+/* Styling for the interactive tutorials (prototype).
+ *
+ * Two panes side by side, filling the viewport below the navbar: the lesson
+ * prose scrolls on the left, the Scastie editor fills the right. Colours come
+ * from Bulma's custom properties so the panes follow the rest of the site.
+ */
+
+/* main.template.html wraps the page body in ; make it a flex container
+ * so the layout below can claim the remaining height. */
+main:has(.tutorial-layout) {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+/* --tutorial-navbar-height is measured and set by tutorials.js. */
+.tutorial-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ height: calc(100vh - var(--tutorial-navbar-height, 5.5rem));
+ min-height: 32rem;
+}
+
+/* Left pane: prose, with the lesson navigation pinned to the bottom. */
+
+.tutorial-prose {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid var(--bulma-border);
+}
+
+.tutorial-prose-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 2rem 2.5rem;
+}
+
+.tutorial-prose-body > h1 {
+ margin-bottom: 1.5rem;
+}
+
+.tutorial-nav {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 2.5rem;
+ border-top: 1px solid var(--bulma-border);
+ background-color: var(--bulma-scheme-main-bis);
+}
+
+.tutorial-nav-slot {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.tutorial-nav-centered {
+ text-align: center;
+}
+
+.tutorial-nav-end {
+ text-align: right;
+}
+
+.tutorial-nav .bulma-button {
+ max-width: 100%;
+ white-space: normal;
+ height: auto;
+ text-align: left;
+}
+
+/* Right pane: the editor and its toolbar. */
+
+.tutorial-editor {
+ position: relative; /* anchors the toast */
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.tutorial-editor-bar {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.5rem 1rem;
+ border-bottom: 1px solid var(--bulma-border);
+ background-color: var(--bulma-scheme-main-bis);
+}
+
+.tutorial-editor-label {
+ color: var(--bulma-text-weak);
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.tutorial-editor-pane {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ background-color: var(--bulma-scheme-main-bis);
+}
+
+/* Shown until Scastie takes over the element (and left in place if the embed
+ * fails to load, so the code is still readable). */
+.tutorial-code {
+ margin: 0;
+ padding: 1rem;
+ font-family: var(--code-font);
+ font-size: 0.9rem;
+ white-space: pre;
+ overflow: auto;
+ background-color: transparent;
+ color: var(--bulma-text);
+}
+
+.tutorial-toast {
+ position: absolute;
+ right: 1.5rem;
+ bottom: 1.5rem;
+ padding: 0.5rem 0.9rem;
+ border-radius: 0.4rem;
+ background-color: var(--bulma-text);
+ color: var(--bulma-scheme-main);
+ font-size: 0.85rem;
+}
+
+/* Scastie's own stylesheet gives the embed a fixed 800px width and a capped
+ * height; stretch it to fill the pane instead. */
+.tutorial-editor-pane .scastie.embedded {
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ border: none;
+}
+
+.tutorial-editor-pane .scastie.embedded .content {
+ height: 100%;
+}
+
+/* Scastie's default editor type is large for a half-width pane. */
+.tutorial-editor-pane .scastie .cm-scroller {
+ font-size: 14px;
+}
+
+/* Scastie overlays its own "open in Scastie" icon on the editor; keep it, but
+ * tone it down so it doesn't sit on top of the first line of code. Our labelled
+ * button in the toolbar clicks this same control. */
+.tutorial-editor-pane .scastie .embedded-overlay li.logo {
+ opacity: 0.45;
+}
+
+.tutorial-editor-pane .scastie .embedded-overlay li.logo:hover {
+ opacity: 1;
+}
+
+/* The site's Laika code styling paints every
with the dark syntax
+ * background; Scastie's console output must not inherit that. */
+.tutorial-editor-pane .scastie pre {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+}
+
+/* Scastie paints its chrome for its own dark theme; blend it into the site.
+ * !important because Scastie injects its stylesheet after ours. */
+.tutorial-editor-pane .scastie .main-panel,
+.tutorial-editor-pane .scastie .editor-wrapper,
+.tutorial-editor-pane .scastie .cm-gutters,
+.tutorial-editor-pane .scastie nav.editor-mobile,
+.tutorial-editor-pane .scastie .switcher-hide,
+.tutorial-editor-pane .scastie .switcher-show,
+.tutorial-editor-pane .scastie .handler,
+.tutorial-editor-pane .scastie li.logo {
+ background: var(--bulma-scheme-main-bis) !important;
+}
+
+.tutorial-editor-pane .scastie .cm-gutters {
+ border-right: 1px solid var(--bulma-border) !important;
+}
+
+.tutorial-editor-pane .scastie .console {
+ background: var(--bulma-scheme-main-ter) !important;
+}
+
+/* Console output and the console switcher are white text on Scastie's dark
+ * bar, which is unreadable on our light one. */
+.tutorial-editor-pane .scastie .console,
+.tutorial-editor-pane .scastie .console pre,
+.tutorial-editor-pane .scastie .output-console,
+.tutorial-editor-pane .scastie .console-label,
+.tutorial-editor-pane .scastie .switcher-hide,
+.tutorial-editor-pane .scastie .switcher-show {
+ color: var(--bulma-text) !important;
+}
+
+/* Scastie's Run pill is powder blue with white text: fine on its own dark
+ * chrome, low contrast on ours. Use the site's primary colour. */
+.tutorial-editor-pane .scastie li.run-button {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 0.35rem;
+ margin-left: 0.75rem !important;
+ padding: 0.35rem 0.85rem !important;
+ border-radius: 0.5rem !important;
+ background: var(--bulma-primary) !important;
+ color: var(--bulma-primary-invert) !important;
+ font-size: 0.9rem;
+}
+
+.tutorial-editor-pane .scastie li.run-button:hover {
+ background: var(--bulma-primary-dark) !important;
+}
+
+/* Stack the panes on narrow screens. */
+@media screen and (max-width: 1023px) {
+ .tutorial-layout {
+ grid-template-columns: minmax(0, 1fr);
+ height: auto;
+ min-height: 0;
+ }
+
+ .tutorial-prose {
+ border-right: none;
+ }
+
+ .tutorial-prose-body {
+ overflow-y: visible;
+ padding: 1.5rem;
+ }
+
+ .tutorial-nav {
+ padding: 0.75rem 1.5rem;
+ }
+
+ .tutorial-editor-pane {
+ height: 70vh;
+ }
+}
diff --git a/src/tutorials/tutorials.js b/src/tutorials/tutorials.js
new file mode 100644
index 00000000..e8ceff17
--- /dev/null
+++ b/src/tutorials/tutorials.js
@@ -0,0 +1,151 @@
+/* Interactive tutorials (prototype).
+ *
+ * Loads the lesson's sample code from the .sc file named by the
+ * `data-code-src` attribute, hands it to an embedded Scastie editor, and wires
+ * up the "Open in Scastie" button.
+ */
+(function () {
+ "use strict";
+
+ var SCASTIE_URL = "https://scastie.scala-lang.org/";
+
+ function ready(fn) {
+ if (document.readyState !== "loading") fn();
+ else document.addEventListener("DOMContentLoaded", fn);
+ }
+
+ function showToast(message) {
+ var toast = document.getElementById("tutorial-toast");
+ if (!toast) return;
+ toast.textContent = message;
+ toast.hidden = false;
+ window.setTimeout(function () {
+ toast.hidden = true;
+ }, 4000);
+ }
+
+ // Options for scastie.Embedded, taken from the page's Laika config (see
+ // /tutorials/directory.conf) with sane fallbacks.
+ function embedOptions(pre) {
+ var options = {
+ theme: "light", // the site is light-only (see main.template.html)
+ isWorksheetMode: pre.dataset.worksheet !== "false",
+ targetType: pre.dataset.targetType || "scala-cli"
+ };
+ if (pre.dataset.scalaVersion) options.scalaVersion = pre.dataset.scalaVersion;
+ return options;
+ }
+
+ function embed(pre) {
+ if (!window.scastie) {
+ // embedded.js failed to load; the plain
stays, which at least
+ // leaves the sample code readable.
+ console.warn("Scastie embedded.js is unavailable; showing static code.");
+ return;
+ }
+ var options = embedOptions(pre);
+ try {
+ scastie.Embedded("#" + pre.id, options);
+ } catch (e) {
+ console.warn("Scastie embed failed for target " + options.targetType + ":", e);
+ if (options.targetType !== "scala3") {
+ // Last resort: a plain Scala 3 editor. Any `//> using` directives in
+ // the lesson won't take effect, but the editor still works.
+ options.targetType = "scala3";
+ try {
+ scastie.Embedded("#" + pre.id, options);
+ } catch (e2) {
+ console.warn("Scastie embed failed:", e2);
+ }
+ }
+ }
+ }
+
+ // The code as it currently stands in the editor, falling back to the code we
+ // loaded if Scastie hasn't mounted (or has changed its DOM under us).
+ function currentCode(originalCode) {
+ var content = document.querySelector(".scastie .cm-content");
+ var edited = content ? content.innerText : "";
+ return edited.trim() ? edited : originalCode;
+ }
+
+ function openInScastie(originalCode) {
+ // Scastie's embed comes with its own "open in Scastie" control (the small
+ // external-link icon it overlays on the editor), which saves the current
+ // editor contents as a snippet and opens it. Delegate to it, so we get the
+ // user's edits and don't have to reimplement the save API.
+ var overlayButton = document.querySelector(".scastie .embedded-overlay li.logo");
+ if (overlayButton) {
+ overlayButton.click();
+ return;
+ }
+
+ // Fallback for when the embed didn't mount (or Scastie changed its DOM):
+ // hand the code over in the URL, and put it on the clipboard too in case
+ // Scastie ignores the parameter.
+ var code = currentCode(originalCode);
+ var opened = window.open(SCASTIE_URL + "?code=" + encodeURIComponent(code), "_blank", "noopener");
+ if (!opened) {
+ showToast("Scastie was blocked from opening in a new tab.");
+ return;
+ }
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(code).then(
+ function () {
+ showToast("Opened Scastie. Your code is also on the clipboard.");
+ },
+ function () {
+ /* clipboard unavailable; the query parameter is all we have */
+ }
+ );
+ }
+ }
+
+ // The panes fill the viewport below the navbar, whose height depends on the
+ // font metrics, so measure it rather than guessing.
+ function trackNavbarHeight() {
+ var navbar = document.querySelector("nav.bulma-navbar");
+ if (!navbar) return;
+ var apply = function () {
+ document.documentElement.style.setProperty("--tutorial-navbar-height", navbar.offsetHeight + "px");
+ };
+ apply();
+ if (window.ResizeObserver) new ResizeObserver(apply).observe(navbar);
+ else window.addEventListener("resize", apply);
+ }
+
+ ready(function () {
+ var pre = document.getElementById("tutorial-code");
+ if (!pre) return;
+
+ trackNavbarHeight();
+
+ var button = document.getElementById("open-in-scastie");
+ var source = pre.dataset.codeSrc;
+ if (!source) {
+ pre.textContent = "This lesson does not name a code file (set `scastie.code` in its header).";
+ if (button) button.disabled = true;
+ return;
+ }
+
+ fetch(source)
+ .then(function (response) {
+ if (!response.ok) throw new Error(response.status + " " + response.statusText);
+ return response.text();
+ })
+ .then(function (code) {
+ var trimmed = code.replace(/\s+$/, "");
+ pre.textContent = trimmed;
+ embed(pre);
+ if (button) {
+ button.addEventListener("click", function () {
+ openInScastie(trimmed);
+ });
+ }
+ })
+ .catch(function (error) {
+ pre.textContent = "Could not load " + source + " (" + error.message + ").";
+ if (button) button.disabled = true;
+ });
+ });
+})();