mdoc Conventions
Choose an mdoc modifier for every executable Scala block based on whether you need scope sharing and whether output should render.
Mandatory: any block a reader could paste and run — it has an import, a bound val/def with a body, a call, or an evaluated expression — MUST carry an mdoc modifier (mdoc:compile-only if unsure). Plain ```scala hides code from the compiler, so use it ONLY for genuinely non-runnable content: abstract signature blocks (declarations with no bodies), pseudocode, ASCII diagrams, sbt/config. Never downgrade a real example to plain ```scala to dodge a compile error — fix the example.
Public API only. An mdoc block compiles in the default package, exactly like user code — reference only PUBLIC symbols. A private/private[pkg]/protected type or member won't compile in a snippet, so never demonstrate a closed extension point: ✅ implement the public LogRecordProcessor ❌ implement LogFormatter whose required parameter type is private[telemetry]. Verify visibility against the real source before drafting; if the only path needs a package-private symbol, pick a public alternative or drop the example.
Modifiers
mdoc:compile-only— Renders source only, isolated scope. Default for self-contained examples. Later blocks cannot reference its definitions.mdoc:silent— Renders nothing, scope shared with later blocks. Use to define types/values/imports later blocks reference. Cannot redefine a name later — usesilent:nestfor that.mdoc:silent:nest— Renders nothing, scope shared, code wrapped in an anonymousobject. Lets you shadow/redefine names from earlier blocks.mdoc:silent:reset— Renders nothing, clears all prior scope. Use when switching to a completely different context mid-document. Because it clears scope, re-declare every import (and any setup) the block's code uses — nothing from earlier blocks carries over, so a missingimporthere is a "not found" compile error.mdoc(no qualifier) — Renders source + evaluated output, scope shared. Shows the code and its REPL-style result.mdoc:invisible— Invisible block, scope shared. Rare; prefersilentorcompile-only.mdoc:embed:<path>— Custom modifier: replaces the block (leave its body empty) with the file at<path>(repo-root relative) as a titled code fence. Append:showLineNumbersfor line numbers. Requires the docs subproject to depend on"dev.zio" %% "zio-sbt-source"— add it if missing.- Plain
```scala(no mdoc) — Source only, not compiled. ONLY for non-runnable content: abstract signature blocks (no method bodies), pseudocode, ASCII diagrams, sbt config. Never for a block with imports/calls/bound bodies.
Never hardcode expression output in comments (val x = 42 // 42). Let mdoc render it.
Choosing the Right Modifier
Is this block runnable (imports / bound bodies / calls / expressions)?
├─ NO → plain ```scala (pseudocode, ASCII art, abstract signatures — no bodies)
└─ YES → Do later blocks need these definitions?
├─ NO → Show the output? NO → mdoc:compile-only YES → mdoc
└─ YES → Is this a later block showing a result?
├─ YES → mdoc
└─ NO → Redefining an earlier name? YES → mdoc:silent:nest NO → mdoc:silent
After a mdoc:silent block, if you need a completely different context, use mdoc:silent:reset.
Before finishing, scan every ```scala fence: if its body is runnable and it has no mdoc modifier, add one.
Common Patterns
- Silent setup + output:
mdoc:silentblock defines helpers/imports; a followingmdocblock calls them and shows the result. - Self-contained: a single
mdoc:compile-onlyblock that stands alone. - Multi-example suite: when a page has many independent examples reusing names like
user/file/config, start every example's first block withmdoc:silent:resetto avoid "Conflicting definitions" errors — once per independent example. Each such reset block must re-declare the imports its code uses (scope was cleared).
For Tutorials (Linear Learning Path)
A tutorial builds one concept on the previous, so favor a shared, accumulating scope:
- First setup block →
mdoc:silent(imports, base types). - Each concept block that has meaningful output →
mdoc(shows the result the learner should see). - Redefining a type to add a field mid-tutorial →
mdoc:silent:nest. - "Putting It Together" final block →
mdoc:embed:<path>pointing at the companionCompleteExample.scala(single source of truth; the file is compiled by the examples build).
Only :reset when the tutorial deliberately restarts in a new domain — rare in a linear tutorial.
Tabbed Scala 2 / Scala 3 Examples
Tab ONLY when the code a reader writes differs (given vs implicit, enum vs sealed trait), never for internal decl/impl differences (macro vs inline def) with an identical call site — state those in one sentence. When it does differ, add these imports after the frontmatter:
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Use groupId="scala-version" (syncs all tab groups on the page) and defaultValue="scala2". Keep blank lines inside <TabItem> so mdoc processes the fenced blocks. Use mdoc:compile-only inside tabs.
Docusaurus Admonitions
Use admonitions sparingly (at most 3-4 per page) for genuinely important callouts:
:::note[Optional title]
Context or clarification the learner should remember.
:::
:::tip
A practical shortcut or pattern.
:::
:::caution
A common mistake to watch out for.
:::
Docs classpath
mdoc compiles against the docs project's .dependsOn(...). If the documented module is missing there, add it to build.sbt (match sibling style, e.g. <module>.jvm) and reload — never downgrade real code to plain ```scala over a missing dependency.
A build's gap is fixed in the build, never in the page. key not found: VERSION means the docs project defines no mdocVariables, so add mdocVariables += "VERSION" -> version.value there and re-run:
✅ mdocVariables += "VERSION" -> version.value in build.sbt ❌ writing % "0.1.0" into the page (it reads as fixed, and it breaks writing-style rule 25)
Verifying
Always compile scoped to the files you touched, never the whole docs set
(unscoped sbt docs/mdoc recompiles every doc, minutes of sbt) — the sole
exception is the integrator's site-build-gate fallback (see docs-integrator):
sbt "docs/mdoc --in <file> --out website/<file>"
mdoc is an sbt task, not a shell binary: quote the whole docs/mdoc … as one argument (never bare mdoc, never unquoted).
One --in/--out pair per file; out is the same path prefixed with
website/, e.g. docs/reference/x.md → website/docs/reference/x.md. If the
failure output only shows a stack trace with "stack trace is suppressed; run
'last '", run that sbt "last <scope>" command to get the real error.
If mdoc produces more than ~3 errors, the blocks are likely not isolated — check for a missing :reset/:nest or a name collision. Strip modifiers from the reported lines, confirm the errors clear, then re-apply one at a time, re-running after each change.