diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 120000
index 00000000000..be77ac83a18
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1 @@
+../AGENTS.md
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4432285793e..366a938f9dc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,10 +1,9 @@
name: build
on:
pull_request:
- branches:
- - "main"
jobs:
+ # Existing Jekyll build job – kept intact during the migration parallel phase.
build:
runs-on: ubuntu-latest
@@ -42,3 +41,33 @@ jobs:
./scripts/build
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Laika/Scala build validation job – added as part of migration workstream W0.5.
+ # Runs on every PR alongside the Jekyll job; does NOT deploy output.
+ laika-build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Cache Scala-CLI build artifacts
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/scala-cli
+ key: scala-cli-${{ runner.os }}-${{ hashFiles('build.scala', 'src/**/*.scala') }}
+ restore-keys: |
+ scala-cli-${{ runner.os }}-
+
+ - name: Install Scala CLI
+ uses: VirtusLab/scala-cli-setup@v1
+
+ - name: Check build
+ run: |
+ scala-cli compile --server=false ./build.scala
+
+ - name: Laika build (validation only)
+ run: |
+ scala-cli run --server=false build.scala -- build --out _site-laika
diff --git a/.gitignore b/.gitignore
index 3809daef021..5aaed07f7b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
_site
+_site-laika
+_site-jekyll
.sass-cache
.DS_Store
.ruby-version
diff --git a/.scalafmt.conf b/.scalafmt.conf
new file mode 100644
index 00000000000..05c544e5108
--- /dev/null
+++ b/.scalafmt.conf
@@ -0,0 +1,17 @@
+version = 3.10.0
+
+runner.dialect = scala3
+runner.dialectOverride.allowSignificantIndentation = false
+runner.dialectOverride.allowQuietSyntax = true
+
+maxColumn = 100
+indent.main = 2
+indent.callSite = 2
+indent.extendSite = 2
+
+newlines.source = keep
+rewrite.scala3.convertToNewSyntax = true
+rewrite.scala3.removeOptionalBraces = false
+
+rewrite.rules = [Imports]
+rewrite.imports.sort = scalastyle
diff --git a/AGENTS.md b/AGENTS.md
index ce2f68c699a..6230f494cdf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,10 +4,14 @@ Use `plans/tech-conversion-to-laika.md` for migration scope and sequencing.
## Scala and Build Rules
-- Build tool is `Scala-CLI` only. Do not add `sbt` files.
+- Build tool is `Scala-CLI` only (available as standalone script as `./scala`). Do not add `sbt` files.
- Use Scala 3.
- Required scalac options: `--no-indent --rewrite`.
- Keep `build.scala` at repo root as a thin entrypoint.
+- To keep Scala dependencies up to date:
+ `./scala --power dependency-update ./build.scala --all`
+- Verify Scala code compiles by running:
+ `./scala compile ./build.scala`.
## Project Organization Rules
diff --git a/Makefile b/Makefile
index 40ff372d007..e37ba70ea85 100644
--- a/Makefile
+++ b/Makefile
@@ -6,5 +6,19 @@ build: hooks
hooks: ./.git/hooks/pre-commit
+scala-edit:
+ rm -rf .idea/ .bsp .metals .scala-build
+ ./scala setup-ide ./build.scala
+ code . --goto ./build.scala
+
+scala-compile:
+ ./scala compile ./build.scala
+
+scala-build:
+ ./scala ./build.scala -- build --out _site-laika
+
+scala-update-dependencies:
+ ./scala --power dependency-update ./build.scala --all
+
./.git/hooks/pre-commit:
./scripts/install-hooks
diff --git a/build.scala b/build.scala
new file mode 100644
index 00000000000..a915028c9e5
--- /dev/null
+++ b/build.scala
@@ -0,0 +1,27 @@
+//> using scala "3.8.2"
+//> using options "-no-indent" "-rewrite"
+//> using files "./src/"
+//> using dep "org.typelevel::laika-io:1.3.2"
+//> using dep "org.typelevel::cats-effect:3.6.3"
+//> using dep "com.monovore::decline-effect:2.6.0"
+//> using dep "org.slf4j:slf4j-nop:2.0.17"
+
+import com.monovore.decline.effect.CommandIOApp
+import com.monovore.decline.{Command, Opts}
+import cats.effect.{IO, ExitCode}
+
+/** Thin Scala-CLI command entrypoint for the Laika build.
+ *
+ * Run with:
+ * ./scala build.scala -- build [--out
]
+ * ./scala build.scala -- serve [--port ]
+ * ./scala build.scala -- verify
+ */
+object Main extends CommandIOApp(
+ name = "build",
+ header = "alexn.org site build tool (Laika)"
+) {
+ override def main: Opts[IO[ExitCode]] = {
+ Build.command
+ }
+}
diff --git a/scala b/scala
new file mode 100755
index 00000000000..bbde4f901eb
--- /dev/null
+++ b/scala
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+
+# This is the launcher script of Scala CLI (https://github.com/VirtusLab/scala-cli).
+# This script downloads and runs the Scala CLI version set by SCALA_CLI_VERSION below.
+#
+# Download the latest version of this script at https://github.com/VirtusLab/scala-cli/raw/main/scala-cli.sh
+
+set -eu
+
+SCALA_CLI_VERSION="1.12.4"
+
+GH_ORG="VirtusLab"
+GH_NAME="scala-cli"
+
+if [ "$SCALA_CLI_VERSION" == "nightly" ]; then
+ TAG="nightly"
+else
+ TAG="v$SCALA_CLI_VERSION"
+fi
+
+if [ "$(expr substr $(uname -s) 1 5 2>/dev/null)" == "Linux" ]; then
+ arch=$(uname -m)
+ if [[ "$arch" == "aarch64" ]] || [[ "$arch" == "x86_64" ]]; then
+ SCALA_CLI_URL="https://github.com/$GH_ORG/$GH_NAME/releases/download/$TAG/scala-cli-${arch}-pc-linux.gz"
+ else
+ echoerr "scala-cli is not supported on $arch"
+ exit 2
+ fi
+ CACHE_BASE="$HOME/.cache/coursier/v1"
+elif [ "$(uname)" == "Darwin" ]; then
+ arch=$(uname -m)
+ CACHE_BASE="$HOME/Library/Caches/Coursier/v1"
+ if [[ "$arch" == "x86_64" ]]; then
+ SCALA_CLI_URL="https://github.com/$GH_ORG/$GH_NAME/releases/download/$TAG/scala-cli-x86_64-apple-darwin.gz"
+ elif [[ "$arch" == "arm64" ]]; then
+ SCALA_CLI_URL="https://github.com/$GH_ORG/$GH_NAME/releases/download/$TAG/scala-cli-aarch64-apple-darwin.gz"
+ else
+ echoerr "scala-cli is not supported on $arch"
+ exit 2
+ fi
+else
+ echo "This standalone scala-cli launcher is supported only in Linux and macOS. If you are using Windows, please use the dedicated launcher scala-cli.bat"
+ exit 1
+fi
+
+CACHE_DEST="$CACHE_BASE/$(echo "$SCALA_CLI_URL" | sed 's@://@/@')"
+SCALA_CLI_BIN_PATH=${CACHE_DEST%.gz}
+
+if [ ! -f "$CACHE_DEST" ]; then
+ mkdir -p "$(dirname "$CACHE_DEST")"
+ TMP_DEST="$CACHE_DEST.tmp-setup"
+ echo "Downloading $SCALA_CLI_URL"
+ curl -fLo "$TMP_DEST" "$SCALA_CLI_URL"
+ mv "$TMP_DEST" "$CACHE_DEST"
+fi
+
+if [ ! -f "$SCALA_CLI_BIN_PATH" ]; then
+ gunzip -k "$CACHE_DEST"
+fi
+
+if [ ! -x "$SCALA_CLI_BIN_PATH" ]; then
+ chmod +x "$SCALA_CLI_BIN_PATH"
+fi
+
+exec "$SCALA_CLI_BIN_PATH" "$@"
diff --git a/src/Build.scala b/src/Build.scala
new file mode 100644
index 00000000000..b163f32fc32
--- /dev/null
+++ b/src/Build.scala
@@ -0,0 +1,123 @@
+import cats.effect.{IO, ExitCode, Resource}
+import cats.syntax.all.*
+import com.monovore.decline.{Command, Opts}
+import laika.api.Transformer
+import laika.format.{Markdown, HTML}
+import laika.io.syntax.*
+import laika.io.model.{InputTree, InputTreeBuilder}
+import laika.io.api.TreeTransformer
+import laika.ast.Path.Root
+import extensions.CompatBundle
+
+/** Main build pipeline orchestration.
+ *
+ * Subcommands (wired via Decline):
+ * build -- render all content to _site-laika/ (or --out )
+ * serve -- start a local preview server (--port , default 4000)
+ * verify -- compare selected Laika output against Jekyll baseline
+ */
+object Build {
+
+ // ---------------------------------------------------------------------------
+ // CLI model (Decline)
+
+ private val buildCmd: Command[IO[ExitCode]] = {
+ val outDir = Opts
+ .option[String]("out", help = "Output directory (default: _site-laika)", short = "o")
+ .withDefault("_site-laika")
+ Command("build", "Render the site to an output directory") {
+ outDir.map(runBuild)
+ }
+ }
+
+ private val serveCmd: Command[IO[ExitCode]] = {
+ val port = Opts
+ .option[Int]("port", help = "Port for the preview server (default: 4000)", short = "p")
+ .withDefault(4000)
+ Command("serve", "Start a local preview server") {
+ port.map(runServe)
+ }
+ }
+
+ private val verifyCmd: Command[IO[ExitCode]] = {
+ Command("verify", "Compare Laika output against Jekyll baseline") {
+ Opts(runVerify())
+ }
+ }
+
+ /** Top-level Decline command exposed to `Main` in `build.scala`. */
+ val command: Opts[IO[ExitCode]] = {
+ Opts.subcommands(buildCmd, serveCmd, verifyCmd)
+ }
+
+ // ---------------------------------------------------------------------------
+ // Transformer
+
+ /** Laika transformer with GitHub-flavoured Markdown and the site-specific
+ * compatibility bundle. Returned as a `Resource` so that the underlying
+ * thread pool is properly released on completion or error.
+ */
+ def transformerResource: Resource[IO, TreeTransformer[IO]] = {
+ Transformer
+ .from(Markdown)
+ .to(HTML)
+ .using(Markdown.GitHubFlavor, CompatBundle)
+ .parallel[IO]
+ .build
+ }
+
+ // ---------------------------------------------------------------------------
+ // Input tree
+
+ /** Builds the virtual input tree by merging:
+ * - Static passthrough assets (assets/, robots.txt, manifest.webmanifest,
+ * crossdomain.xml, 404.html, CNAME)
+ * - Standalone pages under docs/
+ *
+ * Blog posts (_posts/) and wiki pages (_wiki/) will be wired in W2 once
+ * the permalink / front-matter compatibility layer is in place.
+ */
+ def inputTree: InputTreeBuilder[IO] = {
+ InputTree[IO]
+ // Static assets: preserve the assets/ prefix in the output tree
+ .addDirectory("assets", Root / "assets")
+ // Passthrough files expected at the root of the generated site
+ .addFile("robots.txt", Root / "robots.txt")
+ .addFile("manifest.webmanifest", Root / "manifest.webmanifest")
+ .addFile("crossdomain.xml", Root / "crossdomain.xml")
+ .addFile("404.html", Root / "404.html")
+ .addFile("CNAME", Root / "CNAME")
+ // Static HTML/markdown pages under docs/
+ .addDirectory("docs", Root / "docs")
+ }
+
+ // ---------------------------------------------------------------------------
+ // Commands
+
+ def runBuild(outDir: String): IO[ExitCode] = {
+ transformerResource.use { transformer =>
+ transformer
+ .fromInput(inputTree)
+ .toDirectory(outDir)
+ .transform
+ .as(ExitCode.Success)
+ }
+ }
+
+ def runServe(port: Int): IO[ExitCode] = {
+ // Preview server via laika.preview.ServerBuilder will be wired in a
+ // later workstream once the full input tree is stable.
+ IO.println(
+ s"Preview server on port $port is not yet implemented. " +
+ "Use 'bundle exec jekyll serve' in the meantime."
+ ) *> IO.pure(ExitCode.Error)
+ }
+
+ def runVerify(): IO[ExitCode] = {
+ // Parity comparison against Jekyll baseline will be implemented in W11.
+ IO.println(
+ "Verify: side-by-side Jekyll vs Laika comparison not yet implemented."
+ ) *> IO.pure(ExitCode.Success)
+ }
+}
+
diff --git a/src/SiteConfig.scala b/src/SiteConfig.scala
new file mode 100644
index 00000000000..b5b55777705
--- /dev/null
+++ b/src/SiteConfig.scala
@@ -0,0 +1,28 @@
+/** Site-wide metadata, mirroring the values from Jekyll's _config.yml. */
+object SiteConfig {
+
+ val title: String = "Alexandru Nedelcu"
+ val description: String = "On programming and personal projects"
+ val domain: String = "alexn.org"
+ val url: String = "https://alexn.org"
+ val baseUrl: String = ""
+
+ object Author {
+ val name: String = "Alexandru Nedelcu"
+ val github: String = "alexandru"
+ val linkedin: String = "alexelcu"
+ val mastodon: String = "https://mastodon.social/@alexelcu"
+ val bluesky: String = "https://bsky.app/profile/alexn.org"
+ }
+
+ object RepoEdit {
+ val base: String = "https://github.com/alexandru/alexn.org/blob/main/"
+ }
+
+ val navigation: List[(String, String)] = List(
+ "Blog" -> "/blog/",
+ "Wiki" -> "/wiki/",
+ "About" -> "/about/",
+ "Subscribe" -> "/subscribe/"
+ )
+}
diff --git a/src/extensions/CompatBundle.scala b/src/extensions/CompatBundle.scala
new file mode 100644
index 00000000000..1a71eec5462
--- /dev/null
+++ b/src/extensions/CompatBundle.scala
@@ -0,0 +1,23 @@
+package extensions
+
+import laika.api.bundle.ExtensionBundle
+
+/** Compatibility extension bundle for the Jekyll → Laika migration.
+ *
+ * During the dual-run phase (W1–W11) this bundle provides:
+ * - YAML front-matter extraction and mapping to Laika config values.
+ * - Compatibility interpretation of Liquid constructs kept in source
+ * content ({% link %}, {% post_url %}, {% include youtube.html %},
+ * {% raw %}…{% endraw %}).
+ * - AST rewrite hooks (fence header normalisation, math markers, etc.).
+ * - Renderer overrides for output-specific behaviour.
+ * - Custom directives (YouTube embed, contribution/subscription blocks).
+ *
+ * New workstream implementations (W2-W12) will be added here
+ * incrementally without changing the public API surface.
+ */
+object CompatBundle extends ExtensionBundle {
+
+ override val description: String =
+ "alexn.org Jekyll compatibility bundle"
+}