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;
+      });
+  });
+})();