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" +}