Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

# Runs on every push to main and on pull requests: verifies formatting with
# scalafmt, then compiles and runs the full test suite.
on:
push:
branches:
- main
pull_request:

# Cancel superseded runs for the same ref (e.g. rapid PR pushes).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
cache: sbt

- name: Set up sbt
uses: sbt/setup-sbt@v1

- name: Check formatting, compile, and test
run: sbt "scalafmtCheckAll;test"
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ marklit lives in [mdoc](https://scalameta.org/mdoc/)'s neighborhood. Many ideas
| **Named scopes with inheritance** (`id=foo`, `extends=foo`) | ✗ | **✓** |
| **Append to a named scope** (`extends=foo,append`) | ✗ | **✓** |
| Cross-built dependencies on per-major classpaths | ✗ | **✓** |
| **Top-level definitions** (`opaque type`, `@main`, top-level `given`/`extension`) | ✗ | **✓** |
| **No "local class" warning on enum/ADT pattern matches** | partial | **✓** |
| **Built-in ZIO runtime** (`zio-app` modifier) | ✗ | **✓** |
| Multiple modifiers per block (`silent,id=setup`) | partial | **✓** |
| Scoped-by-default — blocks isolated unless you opt in | ✗ | **✓** |
Expand Down Expand Up @@ -145,6 +147,7 @@ Code fences with the info string `scala marklit:<modifiers>` are processed. Modi
| `crash` | Assert that execution throws. The exception is rendered. |
| `passthrough` | Render the block as-is — no compilation. |
| `zio-app` | Wrap the block as a ZIO program and run it via ZIO's `Runtime.unsafe`. |
| `top-level` | Compile the block verbatim as its own compilation unit (no wrapper). For constructs that are illegal or warn inside a method body — `opaque type`, `@main`, or matching a parameterized `enum`/ADT case. **Compile-only**: shows the code (and any diagnostics), never output. See [Top-level blocks](#top-level-blocks). |
| `id=<name>` | Name this block's scope. |
| `extends=<name>` | Create a child scope inheriting from `<name>`. |
| `extends=<name>,append` | Append to `<name>` instead of branching. Subsequent `extends=<name>` blocks see the appended code. |
Expand All @@ -157,6 +160,47 @@ Examples: `silent,id=setup`, `fail,extends=errors`, `zio-app,scala=3.8.2`. `id`

See [examples/base/src/main/markdown/tutorial.md](examples/base/src/main/markdown/tutorial.md) for a worked example of every feature.

## Top-level blocks

By default every block is wrapped in `object MarklitWrapper: def run(): Unit = …`, so your code is a **method body**. That's usually what you want — but some Scala constructs are only legal, or only warning-free, at the *top level* of a source file:

- `opaque type` and `@main def` are **rejected** inside a method body.
- Matching a *parameterized* `enum`/ADT case against a non-local type warns `the type test for X cannot be checked at runtime because it's a local class` — because an `enum` declared inside `def run()` becomes a *local* class.

Mark such a block `top-level` and marklit compiles it verbatim as its own compilation unit. Because top-level code has no entry point, these blocks are **compile-only** — marklit renders the code (and any compile diagnostics) but never executes them.

> **Why this matters (vs. mdoc).** mdoc wraps every block in a *class*, and its only escape hatch — `reset-object` — wraps in an object *and clears the scope*. By mdoc's own docs that works around `Value class may not be a member of another class` and `The outer reference in this type test cannot be checked at run time`, but an object member still can't be an `opaque type`, `@main`, or top-level `given`/`extension`, and `reset-object` throws away your prior definitions. marklit's `top-level` compiles at genuine file scope **and** hoists the definition forward into a normal executable block (below) — so you can show the type *and* run an example that uses it, warning-free.

```scala marklit:top-level
opaque type Celsius = Double
object Celsius:
def apply(d: Double): Celsius = d
extension (c: Celsius) def value: Double = c
```

**Sharing a top-level definition with an executable example.** Give the top-level block an `id`, then `extends=` it from a normal block. Marklit *hoists* the inherited definition above the wrapper while your example runs inside `run()` — so the example compiles cleanly (no local-class warning) **and** shows its output:

````markdown
```scala marklit:top-level,id=actions
enum CounterAction:
case Inc
case Set(v: Int)
```

```scala marklit:extends=actions
val a: Any = CounterAction.Set(10)
a match
case CounterAction.Set(v) => println(s"set $v")
case _ => println("other")
```
````

Rules:

- `top-level` is strict — it may only be combined with scope options (`id`/`extends`/`append`), a version selector (`scala=3`, `scala=3.7.3`), and `show-warnings`. Pairing it with `silent`, `fail`, `zio-app`, `scala=shared`, etc. is a validation error.
- Scopes are single-kind. A normal block may `extends=` a top-level scope (hoisting its definitions); a `top-level` block may **not** extend or append to a normal scope, and `append` must stay within one kind.
- Version selectors work as usual: `top-level,scala=3.7.3` compiles against that exact version, and an `extends=` consumer must agree on the version. Top-level blocks do not participate in `--page-scope`, but an explicit `extends=` consumer composes with it.

## How marklit reads your Markdown

Marklit is **not** a full Markdown processor. It scans for fenced code blocks and treats everything else as opaque text — headings, lists, tables, links, HTML, your blank lines and trailing whitespace all flow through verbatim. The renderer's job is to splice executed output into the right places, not to rewrite your prose.
Expand Down
23 changes: 14 additions & 9 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,47 @@ ThisBuild / scalaVersion := marklitScalaVersion
ThisBuild / organization := "io.github.russwyte"
// version is derived from git tags by sbt-dynver (tag v0.1.0 -> 0.1.0).

ThisBuild / organizationName := "russwyte"
ThisBuild / organizationName := "russwyte"
ThisBuild / organizationHomepage := Some(url("https://github.com/russwyte"))
ThisBuild / licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt"))
ThisBuild / homepage := Some(url("https://github.com/russwyte/marklit"))
ThisBuild / scmInfo := Some(
ThisBuild / licenses := List(
"Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")
)
ThisBuild / homepage := Some(url("https://github.com/russwyte/marklit"))
ThisBuild / scmInfo := Some(
ScmInfo(
url("https://github.com/russwyte/marklit"),
"scm:git@github.com:russwyte/marklit.git",
"scm:git@github.com:russwyte/marklit.git"
)
)
ThisBuild / developers := List(
Developer(
id = "russwyte",
name = "Russ White",
email = "356303+russwyte@users.noreply.github.com",
url = url("https://github.com/russwyte"),
url = url("https://github.com/russwyte")
)
)
ThisBuild / versionScheme := Some("early-semver")

// Publishing to Sonatype's Central Portal. sbt 1.11+ has built-in support via
// `localStaging` / `publishSigned` / `sonaRelease` — no sbt-sonatype plugin needed.
ThisBuild / publishTo := {
val centralSnapshots = "https://central.sonatype.com/repository/maven-snapshots/"
val centralSnapshots =
"https://central.sonatype.com/repository/maven-snapshots/"
if (isSnapshot.value) Some("central-snapshots" at centralSnapshots)
else localStaging.value
}

// PGP key used to sign published artifacts (sbt-pgp + local gpg keyring).
// CI overrides the key via PGP_KEY_HEX (dedicated GitHub Actions signing key);
// local manual publishing falls back to the personal key on this machine.
usePgpKeyHex(sys.env.getOrElse("PGP_KEY_HEX", "2F64727A87F1BCF42FD307DD8582C4F16659A7D6"))
usePgpKeyHex(
sys.env.getOrElse("PGP_KEY_HEX", "2F64727A87F1BCF42FD307DD8582C4F16659A7D6")
)

// Only the sbt plugin is published; everything else is internal or bundled.
lazy val publishSettings = Seq(
publishMavenStyle := true,
publishMavenStyle := true,
pomIncludeRepository := { _ => false }
)

Expand Down
43 changes: 34 additions & 9 deletions compiler/src/main/scala/marklit/Marklit.scala
Original file line number Diff line number Diff line change
Expand Up @@ -197,21 +197,31 @@ private final class CompilerServiceAdapter(
private def buildContext(
code: String,
priorCode: Vector[String],
isZIOApp: Boolean
isZIOApp: Boolean,
topLevel: Boolean,
topLevelPriorCode: Vector[String]
): ScopeContext =
// Top-level blocks compile verbatim (no wrapper) and never execute, so no
// output marker is injected — a bare `print(marker)` would be illegal at
// file scope. Otherwise mark only when there's prior run-body code to skip.
val marker =
if priorCode.nonEmpty then Some(blockMarker(code, priorCode, isZIOApp))
if topLevel then None
else if priorCode.nonEmpty then
Some(blockMarker(code, priorCode, isZIOApp, topLevelPriorCode))
else None
ScopeContext(
priorCode = priorCode,
outputMarker = marker,
isZIOApp = isZIOApp
isZIOApp = isZIOApp,
topLevel = topLevel,
topLevelPriorCode = topLevelPriorCode
)

private def blockMarker(
code: String,
priorCode: Vector[String],
isZIOApp: Boolean
isZIOApp: Boolean,
topLevelPriorCode: Vector[String]
): String =
val md = MessageDigest.getInstance("SHA-256")
md.update(code.getBytes("UTF-8"))
Expand All @@ -220,6 +230,11 @@ private final class CompilerServiceAdapter(
md.update(p.getBytes("UTF-8"))
md.update(0.toByte)
}
md.update(1.toByte) // separator between priorCode and topLevelPriorCode
topLevelPriorCode.foreach { p =>
md.update(p.getBytes("UTF-8"))
md.update(0.toByte)
}
md.update((if isZIOApp then 1 else 0).toByte)
val hex = md.digest().take(16).map(b => f"$b%02x").mkString
s"__MARKLIT_${hex}__"
Expand Down Expand Up @@ -254,11 +269,16 @@ private final class CompilerServiceAdapter(
scalaVersion: Option[String],
location: Option[Location],
scopeConfig: ScopeConfig,
scopeMode: ScopeMode
scopeMode: ScopeMode,
topLevel: Boolean,
topLevelPriorCode: Vector[String]
): IO[MarklitError, CompileResult] =
val invoke =
compilerFor(scalaVersion).flatMap(
_.compile(code, buildContext(code, priorCode, isZIOApp))
_.compile(
code,
buildContext(code, priorCode, isZIOApp, topLevel, topLevelPriorCode)
)
)
location match
case None => invoke
Expand All @@ -277,7 +297,9 @@ private final class CompilerServiceAdapter(
startLine = loc.startLine,
startColumn = loc.startColumn,
scopeConfig = scopeConfig,
scopeMode = scopeMode
scopeMode = scopeMode,
topLevel = topLevel,
topLevelPriorCode = topLevelPriorCode
)
cache.get(key).flatMap {
case Some(hit) => ZIO.succeed(hit)
Expand All @@ -289,9 +311,12 @@ private final class CompilerServiceAdapter(
priorCode: Vector[String],
isZIOApp: Boolean,
scalaVersion: Option[String],
classFilesDir: Option[Path]
classFilesDir: Option[Path],
topLevel: Boolean,
topLevelPriorCode: Vector[String]
): IO[MarklitError, String] =
val ctx = buildContext(code, priorCode, isZIOApp)
val ctx =
buildContext(code, priorCode, isZIOApp, topLevel, topLevelPriorCode)
compilerFor(scalaVersion).flatMap { c =>
classFilesDir match
case Some(dir) => c.executeFromDir(dir, ctx).map(_.output)
Expand Down
13 changes: 12 additions & 1 deletion compiler/src/main/scala/marklit/compiler/Compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,25 @@ final case class ScopeContext(
scalacOptions: Vector[String] = Vector.empty,
outputMarker: Option[String] =
None, // UUID marker to identify where new output starts
isZIOApp: Boolean = false // Wrap in ZIOAppDefault instead of plain object
isZIOApp: Boolean = false, // Wrap in ZIOAppDefault instead of plain object
topLevel: Boolean =
false, // Compile this block verbatim as its own compilation unit
topLevelPriorCode: Vector[String] =
Vector.empty // Inherited definitions hoisted ABOVE the wrapper
):
def append(code: String): ScopeContext =
copy(priorCode = priorCode :+ code)

def allCode: String =
priorCode.mkString("\n\n")

/** Inherited top-level definitions, joined, to emit at file scope (above the
* `MarklitWrapper` object for normal blocks, or as the whole unit for
* top-level blocks).
*/
def hoistedCode: String =
topLevelPriorCode.mkString("\n\n")

object ScopeContext:
val empty: ScopeContext = ScopeContext()

Expand Down
54 changes: 36 additions & 18 deletions compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,10 @@ final class ScalaCompiler(
): IO[MarklitError, CompileResult] =
ZIO
.attempt {
val marker = context.outputMarker.getOrElse("")

// For ZIO blocks, we need to handle markers differently since plain
// print() happens at effect construction time, not execution time.
val (markerCode, codeWithMarker) =
if context.isZIOApp && marker.nonEmpty then
("", s"""zio.ZIO.succeed(print("$marker")) *> {\n$code\n}""")
else
val mc = if marker.nonEmpty then s"""print("$marker")\n""" else ""
(mc, code)

val fullCode =
if context.priorCode.isEmpty then s"$markerCode$codeWithMarker"
else s"${context.allCode}\n\n$markerCode$codeWithMarker"
// Inherited top-level definitions (e.g. an `enum`) are emitted at file
// scope — above the wrapper for normal blocks, or as the entire unit
// for a top-level block. Empty for the common case.
val hoist = context.hoistedCode

// Per-block output directory. Every compile gets its own subdir so
// dotc's `MarklitWrapper$.class` from one block doesn't clobber the
Expand All @@ -75,10 +65,38 @@ final class ScalaCompiler(
val perBlockOut = Files.createTempDirectory(outputDir, "block-")
val sourceFile = Files.createTempFile(perBlockOut, "marklit_", ".scala")
try
val wrappedCode =
if context.isZIOApp then wrapInZIOApp(fullCode)
else wrapInObject(fullCode)
Files.writeString(sourceFile, wrappedCode)
val sourceCode =
if context.topLevel then
// Top-level blocks compile verbatim as their own compilation
// unit: no wrapper, and no `print(marker)` (a bare statement is
// illegal at file scope, and top-level blocks never execute).
if hoist.isEmpty then code else s"$hoist\n\n$code"
else
val marker = context.outputMarker.getOrElse("")

// For ZIO blocks, we handle markers differently since plain
// print() happens at effect construction time, not execution.
val (markerCode, codeWithMarker) =
if context.isZIOApp && marker.nonEmpty then
("", s"""zio.ZIO.succeed(print("$marker")) *> {\n$code\n}""")
else
val mc =
if marker.nonEmpty then s"""print("$marker")\n""" else ""
(mc, code)

val bodyCode =
if context.priorCode.isEmpty then s"$markerCode$codeWithMarker"
else s"${context.allCode}\n\n$markerCode$codeWithMarker"

val wrapped =
if context.isZIOApp then wrapInZIOApp(bodyCode)
else wrapInObject(bodyCode)

// Hoisted top-level definitions go ABOVE the wrapper object so
// they live at file scope; the executable body stays in `run()`.
if hoist.isEmpty then wrapped else s"$hoist\n\n$wrapped"

Files.writeString(sourceFile, sourceCode)

val effectiveClasspath = (classpath ++ context.classpath).toList
val effectiveOpts =
Expand Down
Loading
Loading